@dwk/remotestorage 0.1.0-beta.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.
Files changed (68) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +131 -0
  3. package/dist/auth.d.ts +42 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +108 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/config.d.ts +132 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +67 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/cors.d.ts +23 -0
  12. package/dist/cors.d.ts.map +1 -0
  13. package/dist/cors.js +56 -0
  14. package/dist/cors.js.map +1 -0
  15. package/dist/discovery.d.ts +31 -0
  16. package/dist/discovery.d.ts.map +1 -0
  17. package/dist/discovery.js +35 -0
  18. package/dist/discovery.js.map +1 -0
  19. package/dist/encoding.d.ts +11 -0
  20. package/dist/encoding.d.ts.map +1 -0
  21. package/dist/encoding.js +21 -0
  22. package/dist/encoding.js.map +1 -0
  23. package/dist/folder.d.ts +78 -0
  24. package/dist/folder.d.ts.map +1 -0
  25. package/dist/folder.js +0 -0
  26. package/dist/folder.js.map +1 -0
  27. package/dist/gc.d.ts +23 -0
  28. package/dist/gc.d.ts.map +1 -0
  29. package/dist/gc.js +34 -0
  30. package/dist/gc.js.map +1 -0
  31. package/dist/handler.d.ts +21 -0
  32. package/dist/handler.d.ts.map +1 -0
  33. package/dist/handler.js +166 -0
  34. package/dist/handler.js.map +1 -0
  35. package/dist/index.d.ts +33 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +32 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/jwt.d.ts +35 -0
  40. package/dist/jwt.d.ts.map +1 -0
  41. package/dist/jwt.js +119 -0
  42. package/dist/jwt.js.map +1 -0
  43. package/dist/log.d.ts +29 -0
  44. package/dist/log.d.ts.map +1 -0
  45. package/dist/log.js +27 -0
  46. package/dist/log.js.map +1 -0
  47. package/dist/scope.d.ts +54 -0
  48. package/dist/scope.d.ts.map +1 -0
  49. package/dist/scope.js +83 -0
  50. package/dist/scope.js.map +1 -0
  51. package/dist/storage.d.ts +22 -0
  52. package/dist/storage.d.ts.map +1 -0
  53. package/dist/storage.js +313 -0
  54. package/dist/storage.js.map +1 -0
  55. package/package.json +50 -0
  56. package/src/auth.ts +146 -0
  57. package/src/config.ts +197 -0
  58. package/src/cors.ts +68 -0
  59. package/src/discovery.ts +48 -0
  60. package/src/encoding.ts +22 -0
  61. package/src/folder.ts +0 -0
  62. package/src/gc.ts +50 -0
  63. package/src/handler.ts +225 -0
  64. package/src/index.ts +84 -0
  65. package/src/jwt.ts +155 -0
  66. package/src/log.ts +31 -0
  67. package/src/scope.ts +99 -0
  68. package/src/storage.ts +398 -0
package/src/storage.ts ADDED
@@ -0,0 +1,398 @@
1
+ /**
2
+ * The per-account Durable Object: the single-threaded consistency authority for
3
+ * one remoteStorage vault.
4
+ *
5
+ * The stateless front door (`handler.ts`) authenticates the bearer token and
6
+ * enforces per-module scopes at the edge, then forwards already-authorized
7
+ * requests here, where Cloudflare guarantees one thread per account. Everything
8
+ * that must be strongly consistent — document GET/PUT/DELETE, conditional
9
+ * (`If-Match`/`If-None-Match`) writes, document↔folder conflict detection, the
10
+ * virtual folder listing and its aggregate ETag, and R2 copy-on-write through
11
+ * `@dwk/store` — happens here. This object reuses `@dwk/store` exactly as
12
+ * `@dwk/solid-pod` does (same library, same storage primitives); the only Solid
13
+ * facility it leans on beyond the blob tier is the generic `list(prefix)`
14
+ * projection. Consumers bind this class as a Durable Object namespace.
15
+ */
16
+
17
+ import { DurableObject } from "cloudflare:workers";
18
+
19
+ import {
20
+ createStore,
21
+ d1OrphanSink,
22
+ forwardOrphans,
23
+ PreconditionFailedError,
24
+ type Store,
25
+ type WriteOptions,
26
+ } from "@dwk/store";
27
+
28
+ import { INTERNAL_HEADERS, type RemoteStorageEnv } from "./config";
29
+ import {
30
+ buildFolderModel,
31
+ FOLDER_DESCRIPTION_TYPE,
32
+ hashSignature,
33
+ renderFolderDescription,
34
+ } from "./folder";
35
+ import { isFolderPath } from "./scope";
36
+
37
+ /** Config the front door forwards to the DO (everything else is per-request). */
38
+ interface ForwardedConfig {
39
+ readonly maxInlineBytes?: number;
40
+ }
41
+
42
+ function text(
43
+ status: number,
44
+ body: string,
45
+ headers: HeadersInit = {},
46
+ ): Response {
47
+ return new Response(body, {
48
+ status,
49
+ headers: { "content-type": "text/plain; charset=utf-8", ...headers },
50
+ });
51
+ }
52
+
53
+ export class RemoteStorageObject extends DurableObject<RemoteStorageEnv> {
54
+ #store: Store | null = null;
55
+
56
+ /** Lazily build the store with the front-door's offload threshold. */
57
+ #getStore(config: ForwardedConfig): Store {
58
+ if (this.#store === null) {
59
+ this.#store = createStore(this.ctx, this.env, {
60
+ ...(config.maxInlineBytes !== undefined
61
+ ? { maxInlineBytes: config.maxInlineBytes }
62
+ : {}),
63
+ });
64
+ }
65
+ return this.#store;
66
+ }
67
+
68
+ override async fetch(request: Request): Promise<Response> {
69
+ const config: ForwardedConfig = (() => {
70
+ const raw = request.headers.get(INTERNAL_HEADERS.config);
71
+ if (!raw) return {};
72
+ try {
73
+ return JSON.parse(raw) as ForwardedConfig;
74
+ } catch {
75
+ return {};
76
+ }
77
+ })();
78
+
79
+ const store = this.#getStore(config);
80
+ const url = new URL(request.url);
81
+ // Keep the path percent-encoded: decoding `%2F` would conflate it with a
82
+ // real separator and corrupt store keys.
83
+ const path = url.pathname;
84
+ const method = request.method.toUpperCase();
85
+
86
+ try {
87
+ switch (method) {
88
+ case "HEAD":
89
+ case "GET":
90
+ return isFolderPath(path)
91
+ ? await this.#readFolder(store, path, request, method === "HEAD")
92
+ : await this.#readDocument(store, path, request, method === "HEAD");
93
+ case "PUT":
94
+ return await this.#putDocument(store, path, request);
95
+ case "DELETE":
96
+ return await this.#deleteDocument(store, path, request);
97
+ default:
98
+ return text(405, "Method Not Allowed", { allow: ALLOW });
99
+ }
100
+ } catch (error) {
101
+ if (error instanceof PreconditionFailedError) {
102
+ return text(412, "Precondition Failed");
103
+ }
104
+ if (error instanceof LengthRequiredError) {
105
+ return text(411, "Length Required");
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ // -- document read ---------------------------------------------------------
112
+
113
+ async #readDocument(
114
+ store: Store,
115
+ path: string,
116
+ request: Request,
117
+ headOnly: boolean,
118
+ ): Promise<Response> {
119
+ const meta = store.head(path);
120
+ if (!meta) return text(404, "Not Found");
121
+
122
+ const ifNoneMatch = request.headers.get("if-none-match");
123
+ if (ifNoneMatch && ifNoneMatchSatisfied(ifNoneMatch, meta.etag)) {
124
+ return new Response(null, {
125
+ status: 304,
126
+ headers: { etag: meta.etag, "cache-control": "no-cache" },
127
+ });
128
+ }
129
+
130
+ const blob = await store.readBlob(path);
131
+ // A non-blob pointer at a document path should not arise (writes always go
132
+ // through the blob tier), but guard rather than stream a null body.
133
+ if (!blob) return text(404, "Not Found");
134
+
135
+ const headers = documentHeaders(meta.etag, blob.contentType);
136
+ headers.set("content-length", String(blob.size));
137
+ if (headOnly) {
138
+ await blob.stream.cancel();
139
+ return new Response(null, { status: 200, headers });
140
+ }
141
+ return new Response(blob.stream, { status: 200, headers });
142
+ }
143
+
144
+ // -- folder read -----------------------------------------------------------
145
+
146
+ async #readFolder(
147
+ store: Store,
148
+ path: string,
149
+ request: Request,
150
+ headOnly: boolean,
151
+ ): Promise<Response> {
152
+ // A folder is virtual: its listing and ETag derive from descendants. An
153
+ // empty/absent folder still answers 200 with a stable ETag (over the empty
154
+ // signature), matching remoteStorage's "folders always exist" model.
155
+ const model = buildFolderModel(path, store.list(path));
156
+ const etag = await hashSignature(model.signature);
157
+
158
+ const ifNoneMatch = request.headers.get("if-none-match");
159
+ if (ifNoneMatch && ifNoneMatchSatisfied(ifNoneMatch, etag)) {
160
+ return new Response(null, {
161
+ status: 304,
162
+ headers: { etag, "cache-control": "no-cache" },
163
+ });
164
+ }
165
+
166
+ const headers = new Headers({
167
+ etag,
168
+ "content-type": FOLDER_DESCRIPTION_TYPE,
169
+ "cache-control": "no-cache",
170
+ allow: ALLOW,
171
+ });
172
+ if (headOnly) return new Response(null, { status: 200, headers });
173
+
174
+ const body = JSON.stringify(await renderFolderDescription(model));
175
+ headers.set(
176
+ "content-length",
177
+ String(new TextEncoder().encode(body).length),
178
+ );
179
+ return new Response(body, { status: 200, headers });
180
+ }
181
+
182
+ // -- document write --------------------------------------------------------
183
+
184
+ async #putDocument(
185
+ store: Store,
186
+ path: string,
187
+ request: Request,
188
+ ): Promise<Response> {
189
+ if (isFolderPath(path)) {
190
+ // A folder path (trailing slash) is not a document; remoteStorage has no
191
+ // verb to create a folder directly (draft §6).
192
+ return text(400, "Cannot PUT a folder", { allow: ALLOW });
193
+ }
194
+ // Document↔folder name collisions are forbidden (draft §6): a path that
195
+ // already has descendants is a folder, and any ancestor that is a document
196
+ // would shadow this one.
197
+ const conflict = this.#conflict(store, path);
198
+ if (conflict) return text(409, conflict, { allow: ALLOW });
199
+
200
+ const existed = store.head(path) !== null;
201
+ await this.#writeBody(store, path, request, {
202
+ ifMatch: ifMatchOf(request),
203
+ ifNoneMatch: ifNoneMatchOf(request),
204
+ });
205
+ await this.#drainOrphans(store);
206
+
207
+ const meta = store.head(path);
208
+ const headers = new Headers({ allow: ALLOW });
209
+ if (meta) headers.set("etag", meta.etag);
210
+ return new Response(null, { status: existed ? 200 : 201, headers });
211
+ }
212
+
213
+ // -- document delete -------------------------------------------------------
214
+
215
+ async #deleteDocument(
216
+ store: Store,
217
+ path: string,
218
+ request: Request,
219
+ ): Promise<Response> {
220
+ if (isFolderPath(path)) {
221
+ return text(400, "Cannot DELETE a folder", { allow: ALLOW });
222
+ }
223
+ const meta = store.head(path);
224
+ if (!meta) return text(404, "Not Found");
225
+
226
+ const oldEtag = meta.etag;
227
+ store.delete(path, { ifMatch: ifMatchOf(request) });
228
+ await this.#drainOrphans(store);
229
+
230
+ // The emptied parent folders simply vanish — they are virtual. The deleted
231
+ // document's ETag is returned for clients that track it (draft §6).
232
+ return new Response(null, {
233
+ status: 200,
234
+ headers: { etag: oldEtag, allow: ALLOW },
235
+ });
236
+ }
237
+
238
+ // -- helpers ---------------------------------------------------------------
239
+
240
+ /**
241
+ * Detect a document↔folder name collision for a PUT to `path`. Returns a
242
+ * human-readable reason, or `null` when the write is unobstructed:
243
+ * - `path` already has descendants ⇒ it is a folder, not a document;
244
+ * - an ancestor segment is itself a stored document ⇒ it would shadow `path`.
245
+ */
246
+ #conflict(store: Store, path: string): string | null {
247
+ if (store.list(`${path}/`).length > 0) {
248
+ return "A folder already exists at this path";
249
+ }
250
+ let slash = path.indexOf("/", 1);
251
+ while (slash !== -1) {
252
+ const ancestor = path.slice(0, slash);
253
+ if (ancestor.length > 0 && store.head(ancestor) !== null) {
254
+ return "A document already exists at an ancestor path";
255
+ }
256
+ slash = path.indexOf("/", slash + 1);
257
+ }
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * Forward any blob keys the store outboxed (copy-on-write displacements and
263
+ * deletes) into the shared D1 GC table, so the out-of-band cron can reclaim
264
+ * them without waking this DO. No-op when `GC_DB` is not bound.
265
+ */
266
+ async #drainOrphans(store: Store): Promise<void> {
267
+ if (!this.env.GC_DB) return;
268
+ await forwardOrphans(store, d1OrphanSink(this.env.GC_DB));
269
+ }
270
+
271
+ /**
272
+ * Write a request body as a document blob without ever buffering an oversized
273
+ * body in the DO. A body known (by `Content-Length`) to exceed the offload
274
+ * ceiling is streamed straight to R2; a small or undeclared body is probed up
275
+ * to the ceiling (trusting nothing) and written from memory, while a probe
276
+ * that overflows an undeclared length is rejected `411` rather than buffered.
277
+ */
278
+ async #writeBody(
279
+ store: Store,
280
+ path: string,
281
+ request: Request,
282
+ preconditions: Pick<WriteOptions, "ifMatch" | "ifNoneMatch">,
283
+ ): Promise<void> {
284
+ const contentType =
285
+ request.headers.get("content-type")?.split(";")[0]?.trim() ||
286
+ "application/octet-stream";
287
+ const declared = parseContentLength(request.headers.get("content-length"));
288
+
289
+ if (declared !== null && declared > store.maxInlineBytes) {
290
+ await store.putBlob(path, request.body ?? new Blob([]), {
291
+ contentType,
292
+ ...preconditions,
293
+ });
294
+ return;
295
+ }
296
+
297
+ const peeked = await readUpToLimit(request.body, store.maxInlineBytes);
298
+ if (peeked.kind === "overflow") throw new LengthRequiredError();
299
+ await store.putBlob(path, peeked.bytes, { contentType, ...preconditions });
300
+ }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Module-level helpers
305
+ // ---------------------------------------------------------------------------
306
+
307
+ const ALLOW = "GET, HEAD, PUT, DELETE, OPTIONS";
308
+
309
+ /** Headers common to document responses. */
310
+ function documentHeaders(etag: string, contentType: string): Headers {
311
+ return new Headers({
312
+ etag,
313
+ "content-type": contentType || "application/octet-stream",
314
+ "cache-control": "no-cache",
315
+ allow: ALLOW,
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Whether a stored `etag` satisfies an `If-None-Match` header (RFC 7232 §3.2).
321
+ * `*` matches any current representation; otherwise the comma-separated entity
322
+ * tags are compared with the weak comparison function (the `W/` prefix ignored).
323
+ */
324
+ function ifNoneMatchSatisfied(header: string, etag: string): boolean {
325
+ if (header.trim() === "*") return true;
326
+ const opaque = (tag: string) => tag.trim().replace(/^W\//, "");
327
+ const target = opaque(etag);
328
+ return header.split(",").some((tag) => {
329
+ const candidate = opaque(tag);
330
+ return candidate.length > 0 && candidate === target;
331
+ });
332
+ }
333
+
334
+ function ifMatchOf(request: Request): string | undefined {
335
+ return request.headers.get("if-match") ?? undefined;
336
+ }
337
+
338
+ function ifNoneMatchOf(request: Request): string | undefined {
339
+ return request.headers.get("if-none-match") ?? undefined;
340
+ }
341
+
342
+ /** The outcome of {@link readUpToLimit}: a fully-buffered body or an overflow signal. */
343
+ type PeekedBody =
344
+ | { readonly kind: "buffered"; readonly bytes: Uint8Array }
345
+ | { readonly kind: "overflow" };
346
+
347
+ /** Concatenate read chunks into one `Uint8Array` of `total` bytes. */
348
+ function concatChunks(
349
+ chunks: readonly Uint8Array[],
350
+ total: number,
351
+ ): Uint8Array {
352
+ const out = new Uint8Array(total);
353
+ let offset = 0;
354
+ for (const chunk of chunks) {
355
+ out.set(chunk, offset);
356
+ offset += chunk.byteLength;
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /**
362
+ * Parse a `Content-Length` header into a non-negative integer, or `null` when
363
+ * absent or malformed (treated as "length unknown").
364
+ */
365
+ function parseContentLength(header: string | null): number | null {
366
+ if (header === null || !/^\d+$/.test(header)) return null;
367
+ const value = Number(header);
368
+ return Number.isSafeInteger(value) ? value : null;
369
+ }
370
+
371
+ /**
372
+ * Read at most `limit` bytes from `body` to classify an undeclared-length body
373
+ * without buffering an oversized one. Returns the buffered bytes when the body
374
+ * ends within `limit`; signals `overflow` (cancelling the body) the moment it
375
+ * exceeds `limit`. A body of exactly `limit` bytes still fits.
376
+ */
377
+ async function readUpToLimit(
378
+ body: ReadableStream<Uint8Array> | null,
379
+ limit: number,
380
+ ): Promise<PeekedBody> {
381
+ if (!body) return { kind: "buffered", bytes: new Uint8Array(0) };
382
+ const reader = body.getReader();
383
+ const chunks: Uint8Array[] = [];
384
+ let total = 0;
385
+ for (;;) {
386
+ const { done, value } = await reader.read();
387
+ if (done) return { kind: "buffered", bytes: concatChunks(chunks, total) };
388
+ chunks.push(value);
389
+ total += value.byteLength;
390
+ if (total > limit) {
391
+ await reader.cancel();
392
+ return { kind: "overflow" };
393
+ }
394
+ }
395
+ }
396
+
397
+ /** Thrown by `#writeBody` when an unsized body is too large to buffer or stream (→ 411). */
398
+ class LengthRequiredError extends Error {}