@interocitor/core 0.0.0-beta.10
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/LICENSE +21 -0
- package/README.md +706 -0
- package/dist/adapters/cloudflare.d.ts +78 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +325 -0
- package/dist/adapters/google-drive.d.ts +64 -0
- package/dist/adapters/google-drive.d.ts.map +1 -0
- package/dist/adapters/google-drive.js +339 -0
- package/dist/adapters/memory.d.ts +53 -0
- package/dist/adapters/memory.d.ts.map +1 -0
- package/dist/adapters/memory.js +182 -0
- package/dist/adapters/webdav.d.ts +70 -0
- package/dist/adapters/webdav.d.ts.map +1 -0
- package/dist/adapters/webdav.js +323 -0
- package/dist/core/codec.d.ts +20 -0
- package/dist/core/codec.d.ts.map +1 -0
- package/dist/core/codec.js +102 -0
- package/dist/core/compaction.d.ts +45 -0
- package/dist/core/compaction.d.ts.map +1 -0
- package/dist/core/compaction.js +190 -0
- package/dist/core/connected-stores.d.ts +77 -0
- package/dist/core/connected-stores.d.ts.map +1 -0
- package/dist/core/connected-stores.js +76 -0
- package/dist/core/crdt.d.ts +36 -0
- package/dist/core/crdt.d.ts.map +1 -0
- package/dist/core/crdt.js +174 -0
- package/dist/core/errors.d.ts +47 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +61 -0
- package/dist/core/flush.d.ts +9 -0
- package/dist/core/flush.d.ts.map +1 -0
- package/dist/core/flush.js +98 -0
- package/dist/core/hlc.d.ts +25 -0
- package/dist/core/hlc.d.ts.map +1 -0
- package/dist/core/hlc.js +75 -0
- package/dist/core/ids.d.ts +49 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +132 -0
- package/dist/core/internals.d.ts +33 -0
- package/dist/core/internals.d.ts.map +1 -0
- package/dist/core/internals.js +72 -0
- package/dist/core/manifest.d.ts +56 -0
- package/dist/core/manifest.d.ts.map +1 -0
- package/dist/core/manifest.js +203 -0
- package/dist/core/pull.d.ts +26 -0
- package/dist/core/pull.d.ts.map +1 -0
- package/dist/core/pull.js +113 -0
- package/dist/core/row-id.d.ts +12 -0
- package/dist/core/row-id.d.ts.map +1 -0
- package/dist/core/row-id.js +11 -0
- package/dist/core/schema-types.d.ts +26 -0
- package/dist/core/schema-types.d.ts.map +1 -0
- package/dist/core/schema-types.js +31 -0
- package/dist/core/schema-types.type-test.d.ts +2 -0
- package/dist/core/schema-types.type-test.d.ts.map +1 -0
- package/dist/core/schema-types.type-test.js +224 -0
- package/dist/core/sync-engine.d.ts +364 -0
- package/dist/core/sync-engine.d.ts.map +1 -0
- package/dist/core/sync-engine.js +2475 -0
- package/dist/core/table.d.ts +260 -0
- package/dist/core/table.d.ts.map +1 -0
- package/dist/core/table.js +461 -0
- package/dist/core/types.d.ts +952 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +6 -0
- package/dist/crypto/encryption.d.ts +61 -0
- package/dist/crypto/encryption.d.ts.map +1 -0
- package/dist/crypto/encryption.js +216 -0
- package/dist/crypto/keys.d.ts +48 -0
- package/dist/crypto/keys.d.ts.map +1 -0
- package/dist/crypto/keys.js +54 -0
- package/dist/handshake/channel.d.ts +117 -0
- package/dist/handshake/channel.d.ts.map +1 -0
- package/dist/handshake/channel.js +245 -0
- package/dist/handshake/index.d.ts +216 -0
- package/dist/handshake/index.d.ts.map +1 -0
- package/dist/handshake/index.js +199 -0
- package/dist/handshake/qr-public.d.ts +3 -0
- package/dist/handshake/qr-public.d.ts.map +1 -0
- package/dist/handshake/qr-public.js +1 -0
- package/dist/handshake/qr.d.ts +100 -0
- package/dist/handshake/qr.d.ts.map +1 -0
- package/dist/handshake/qr.js +102 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/storage/credential-store.d.ts +122 -0
- package/dist/storage/credential-store.d.ts.map +1 -0
- package/dist/storage/credential-store.js +356 -0
- package/dist/storage/local-store.d.ts +64 -0
- package/dist/storage/local-store.d.ts.map +1 -0
- package/dist/storage/local-store.js +490 -0
- package/dist/storage/reset.d.ts +10 -0
- package/dist/storage/reset.d.ts.map +1 -0
- package/dist/storage/reset.js +18 -0
- package/package.json +76 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker + D1 Storage Adapter (Interocitor-native protocol)
|
|
3
|
+
*
|
|
4
|
+
* This adapter is a perfect-fit backend for Interocitor only.
|
|
5
|
+
* It does NOT parse XML and does NOT depend on WebDAV compatibility.
|
|
6
|
+
*
|
|
7
|
+
* Base URL shape:
|
|
8
|
+
* https://<worker>/io/<prefix>
|
|
9
|
+
*
|
|
10
|
+
* The adapter derives:
|
|
11
|
+
* wss://<worker>/notify/<prefix> (WebSocket invalidations via InterocitorRelay DO)
|
|
12
|
+
*/
|
|
13
|
+
import type { StorageAdapter, FileEntry, RemoteInvalidationPayload, RemoteInvalidationHooks, StoredFileMetadata, StoredFileWriteOptions } from '../core/types.ts';
|
|
14
|
+
export interface CloudflareAdapterConfig {
|
|
15
|
+
/** Worker IO base URL that includes prefix, e.g. https://worker/io/team-a */
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
/** Optional bearer for server/cost protection (INTEROCITOR_ACCESS_TOKEN). */
|
|
18
|
+
token?: string;
|
|
19
|
+
/** Disable relay/WebSocket invalidations for this client. */
|
|
20
|
+
relayEnabled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Interocitor-native Cloudflare adapter for Worker + D1 based deployments.
|
|
24
|
+
*
|
|
25
|
+
* Use this when you want a purpose-fit backend with optional WebSocket-driven
|
|
26
|
+
* invalidation instead of a generic file protocol like WebDAV.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* const adapter = new CloudflareAdapter({
|
|
31
|
+
* baseUrl: 'https://example.com/io/team-a',
|
|
32
|
+
* token: 'optional-bearer-token',
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
/** Config shape embedded in QR payloads for CloudflareAdapter. Credentials excluded. */
|
|
37
|
+
export interface CloudflareHandshakeConfig {
|
|
38
|
+
/** Worker IO base URL including the /io/<prefix> path segment. */
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
}
|
|
41
|
+
export declare class CloudflareAdapter implements StorageAdapter {
|
|
42
|
+
readonly name = "cloudflare";
|
|
43
|
+
private readonly config;
|
|
44
|
+
private authenticated;
|
|
45
|
+
private ensuredFolders;
|
|
46
|
+
constructor(config: CloudflareAdapterConfig);
|
|
47
|
+
private headers;
|
|
48
|
+
private parseBaseUrl;
|
|
49
|
+
private get ioBaseUrl();
|
|
50
|
+
private get notifyUrl();
|
|
51
|
+
private ioUrl;
|
|
52
|
+
private fileUrl;
|
|
53
|
+
private storedFileUrl;
|
|
54
|
+
authenticate(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Returns the worker base URL (without credentials) for embedding in a QR payload.
|
|
57
|
+
* The scanner uses this to point their CloudflareAdapter at the same worker shard.
|
|
58
|
+
*/
|
|
59
|
+
getHandshakeConfig(): string;
|
|
60
|
+
isAuthenticated(): boolean;
|
|
61
|
+
subscribeToInvalidations(onInvalidate: (payload: RemoteInvalidationPayload) => void, hooks?: RemoteInvalidationHooks): () => void;
|
|
62
|
+
ensureFolder(path: string): Promise<void>;
|
|
63
|
+
/** Drop the per-session ensureFolder cache. Call after mesh swap, poison,
|
|
64
|
+
* or any state where a previous "this folder exists" observation must
|
|
65
|
+
* not be trusted. */
|
|
66
|
+
resetFolderCache(): void;
|
|
67
|
+
listFiles(path: string): Promise<FileEntry[]>;
|
|
68
|
+
listFolders(path: string): Promise<string[]>;
|
|
69
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
70
|
+
writeFile(path: string, data: Uint8Array | string): Promise<void>;
|
|
71
|
+
deleteFile(path: string): Promise<void>;
|
|
72
|
+
getFileMetadata(path: string): Promise<FileEntry | null>;
|
|
73
|
+
putStoredFile(path: string, data: Uint8Array | string, options?: StoredFileWriteOptions): Promise<StoredFileMetadata>;
|
|
74
|
+
getStoredFile(path: string): Promise<Uint8Array>;
|
|
75
|
+
deleteStoredFile(path: string): Promise<void>;
|
|
76
|
+
getStoredFileMetadata(path: string): Promise<StoredFileMetadata | null>;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=cloudflare.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare.d.ts","sourceRoot":"","sources":["../../src/adapters/cloudflare.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,yBAAyB,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAElK,MAAM,WAAW,uBAAuB;IACtC,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAiBD;;;;;;;;;;;;;GAaG;AACH,wFAAwF;AACxF,MAAM,WAAW,yBAAyB;IACxC,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,iBAAkB,YAAW,cAAc;IACtD,QAAQ,CAAC,IAAI,gBAAgB;IAE7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,OAAO,CAAC,aAAa,CAAS;IAM9B,OAAO,CAAC,cAAc,CAA0B;gBAEpC,MAAM,EAAE,uBAAuB;IAI3C,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,YAAY;IAWpB,OAAO,KAAK,SAAS,GAMpB;IAED,OAAO,KAAK,SAAS,GAWpB;IAED,OAAO,CAAC,KAAK;IAKb,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,aAAa;IAMf,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAkBnC;;;OAGG;IACH,kBAAkB,IAAI,MAAM;IAK5B,eAAe,IAAI,OAAO;IAI1B,wBAAwB,CACtB,YAAY,EAAE,CAAC,OAAO,EAAE,yBAAyB,KAAK,IAAI,EAC1D,KAAK,CAAC,EAAE,uBAAuB,GAC9B,MAAM,IAAI;IAsFP,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAe/C;;0BAEsB;IACtB,gBAAgB,IAAI,IAAI;IAIlB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAqB7C,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAe5C,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAa3C,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjE,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWvC,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAuBxD,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,EAAE,OAAO,GAAE,sBAA2B,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAgBzH,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAMhD,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAK7C,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;CAW9E"}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker + D1 Storage Adapter (Interocitor-native protocol)
|
|
3
|
+
*
|
|
4
|
+
* This adapter is a perfect-fit backend for Interocitor only.
|
|
5
|
+
* It does NOT parse XML and does NOT depend on WebDAV compatibility.
|
|
6
|
+
*
|
|
7
|
+
* Base URL shape:
|
|
8
|
+
* https://<worker>/io/<prefix>
|
|
9
|
+
*
|
|
10
|
+
* The adapter derives:
|
|
11
|
+
* wss://<worker>/notify/<prefix> (WebSocket invalidations via InterocitorRelay DO)
|
|
12
|
+
*/
|
|
13
|
+
export class CloudflareAdapter {
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.name = 'cloudflare';
|
|
16
|
+
this.authenticated = false;
|
|
17
|
+
// Per-session cache of folders we have already ensured. Cloudflare's
|
|
18
|
+
// /ensure-folder is idempotent but a POST per folder per connect is
|
|
19
|
+
// pure round-trip overhead — Interocitor.connect re-runs the same
|
|
20
|
+
// 4-folder loop on every reload. Cache wipes via `resetFolderCache()`
|
|
21
|
+
// (mesh swap / poison) or process exit.
|
|
22
|
+
this.ensuredFolders = new Set();
|
|
23
|
+
this.config = { ...config, baseUrl: config.baseUrl.replace(/\/$/, ''), relayEnabled: config.relayEnabled ?? true };
|
|
24
|
+
}
|
|
25
|
+
headers(extra) {
|
|
26
|
+
const base = {};
|
|
27
|
+
if (this.config.token) {
|
|
28
|
+
base.Authorization = `Bearer ${this.config.token}`;
|
|
29
|
+
}
|
|
30
|
+
return { ...base, ...extra };
|
|
31
|
+
}
|
|
32
|
+
parseBaseUrl() {
|
|
33
|
+
const base = /^https?:\/\//i.test(this.config.baseUrl)
|
|
34
|
+
? this.config.baseUrl
|
|
35
|
+
: new URL(this.config.baseUrl, 'http://interocitor').toString();
|
|
36
|
+
const u = new URL(base);
|
|
37
|
+
if (!u.pathname.includes('/io/')) {
|
|
38
|
+
throw new Error('CloudflareAdapter baseUrl must include /io/<prefix>');
|
|
39
|
+
}
|
|
40
|
+
return u;
|
|
41
|
+
}
|
|
42
|
+
get ioBaseUrl() {
|
|
43
|
+
const u = this.parseBaseUrl();
|
|
44
|
+
if (!/^https?:\/\//i.test(this.config.baseUrl)) {
|
|
45
|
+
return `${u.pathname}${u.search}${u.hash}`.replace(/\/$/, '');
|
|
46
|
+
}
|
|
47
|
+
return u.toString().replace(/\/$/, '');
|
|
48
|
+
}
|
|
49
|
+
get notifyUrl() {
|
|
50
|
+
const u = this.parseBaseUrl();
|
|
51
|
+
u.pathname = u.pathname.replace('/io/', '/notify/');
|
|
52
|
+
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
53
|
+
if (this.config.token) {
|
|
54
|
+
u.searchParams.set('access_token', this.config.token);
|
|
55
|
+
}
|
|
56
|
+
if (!/^https?:\/\//i.test(this.config.baseUrl)) {
|
|
57
|
+
return `${u.pathname}${u.search}${u.hash}`.replace(/\/$/, '');
|
|
58
|
+
}
|
|
59
|
+
return u.toString().replace(/\/$/, '');
|
|
60
|
+
}
|
|
61
|
+
ioUrl(pathname) {
|
|
62
|
+
const clean = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
63
|
+
return `${this.ioBaseUrl}${clean}`;
|
|
64
|
+
}
|
|
65
|
+
fileUrl(path) {
|
|
66
|
+
const u = new URL(this.ioUrl('/file'));
|
|
67
|
+
u.searchParams.set('path', path);
|
|
68
|
+
return u.toString();
|
|
69
|
+
}
|
|
70
|
+
storedFileUrl(path) {
|
|
71
|
+
const u = new URL(this.ioUrl('/stored-file'));
|
|
72
|
+
u.searchParams.set('path', path);
|
|
73
|
+
return u.toString();
|
|
74
|
+
}
|
|
75
|
+
async authenticate() {
|
|
76
|
+
const res = await fetch(this.ioUrl('/health'), {
|
|
77
|
+
method: 'GET',
|
|
78
|
+
headers: this.headers(),
|
|
79
|
+
});
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
this.authenticated = true;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (res.status === 401 || res.status === 403) {
|
|
85
|
+
throw new Error('Cloudflare Worker auth failed — check your access token');
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Cloudflare Worker unreachable: HTTP ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Returns the worker base URL (without credentials) for embedding in a QR payload.
|
|
91
|
+
* The scanner uses this to point their CloudflareAdapter at the same worker shard.
|
|
92
|
+
*/
|
|
93
|
+
getHandshakeConfig() {
|
|
94
|
+
const cfg = { baseUrl: this.config.baseUrl };
|
|
95
|
+
return JSON.stringify(cfg);
|
|
96
|
+
}
|
|
97
|
+
isAuthenticated() {
|
|
98
|
+
return this.authenticated;
|
|
99
|
+
}
|
|
100
|
+
subscribeToInvalidations(onInvalidate, hooks) {
|
|
101
|
+
if (this.config.relayEnabled === false) {
|
|
102
|
+
hooks?.onClose?.();
|
|
103
|
+
return () => { };
|
|
104
|
+
}
|
|
105
|
+
let ws = null;
|
|
106
|
+
let cancelled = false;
|
|
107
|
+
let backoffMs = 1000;
|
|
108
|
+
const MAX_BACKOFF_MS = 30000;
|
|
109
|
+
// Attempts where the socket closed before ever opening (failed upgrade).
|
|
110
|
+
// After MAX_FAILED_UPGRADES consecutive such failures we back off to a long
|
|
111
|
+
// cooldown interval rather than hammering (e.g. DO free-tier exhaustion).
|
|
112
|
+
// Once the cooldown elapses we try again — self-healing if the server recovers.
|
|
113
|
+
let failedUpgradeStreak = 0;
|
|
114
|
+
const MAX_FAILED_UPGRADES = 5;
|
|
115
|
+
const COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
|
|
116
|
+
const connect = () => {
|
|
117
|
+
if (cancelled)
|
|
118
|
+
return;
|
|
119
|
+
let opened = false;
|
|
120
|
+
try {
|
|
121
|
+
ws = new WebSocket(this.notifyUrl);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
hooks?.onError?.(error);
|
|
125
|
+
failedUpgradeStreak++;
|
|
126
|
+
scheduleReconnect();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
ws.onopen = () => {
|
|
130
|
+
opened = true;
|
|
131
|
+
failedUpgradeStreak = 0;
|
|
132
|
+
backoffMs = 1000;
|
|
133
|
+
hooks?.onReady?.();
|
|
134
|
+
};
|
|
135
|
+
ws.onmessage = (e) => {
|
|
136
|
+
try {
|
|
137
|
+
const msg = JSON.parse(e.data);
|
|
138
|
+
if (msg.type === 'invalidation' || msg.type === 'invalidate' || msg.type === 'compact') {
|
|
139
|
+
onInvalidate(msg);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
onInvalidate({ type: 'unknown', path: '/', ts: Date.now() });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
ws.onerror = (event) => {
|
|
147
|
+
hooks?.onError?.(event);
|
|
148
|
+
};
|
|
149
|
+
ws.onclose = () => {
|
|
150
|
+
if (!opened) {
|
|
151
|
+
// The upgrade itself failed (server returned non-101, e.g. 500/503).
|
|
152
|
+
failedUpgradeStreak++;
|
|
153
|
+
}
|
|
154
|
+
if (!cancelled) {
|
|
155
|
+
hooks?.onClose?.();
|
|
156
|
+
if (failedUpgradeStreak >= MAX_FAILED_UPGRADES) {
|
|
157
|
+
// Back off to a long cooldown then try again — self-healing if the
|
|
158
|
+
// server recovers (e.g. DO free-tier resets).
|
|
159
|
+
failedUpgradeStreak = 0;
|
|
160
|
+
backoffMs = 1000;
|
|
161
|
+
setTimeout(() => connect(), COOLDOWN_MS);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
scheduleReconnect();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
const scheduleReconnect = () => {
|
|
169
|
+
if (cancelled)
|
|
170
|
+
return;
|
|
171
|
+
setTimeout(() => connect(), backoffMs);
|
|
172
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
173
|
+
};
|
|
174
|
+
connect();
|
|
175
|
+
return () => {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
try {
|
|
178
|
+
ws?.close(1000, 'unsubscribed');
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
ws = null;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async ensureFolder(path) {
|
|
185
|
+
if (this.ensuredFolders.has(path))
|
|
186
|
+
return;
|
|
187
|
+
const res = await fetch(this.ioUrl('/ensure-folder'), {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: this.headers({ 'Content-Type': 'application/json; charset=utf-8' }),
|
|
190
|
+
body: JSON.stringify({ path }),
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok && res.status !== 405) {
|
|
193
|
+
throw new Error(`Failed to ensure folder ${path}: HTTP ${res.status}`);
|
|
194
|
+
}
|
|
195
|
+
this.ensuredFolders.add(path);
|
|
196
|
+
}
|
|
197
|
+
/** Drop the per-session ensureFolder cache. Call after mesh swap, poison,
|
|
198
|
+
* or any state where a previous "this folder exists" observation must
|
|
199
|
+
* not be trusted. */
|
|
200
|
+
resetFolderCache() {
|
|
201
|
+
this.ensuredFolders.clear();
|
|
202
|
+
}
|
|
203
|
+
async listFiles(path) {
|
|
204
|
+
const res = await fetch(this.ioUrl('/list-files'), {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: this.headers({ 'Content-Type': 'application/json; charset=utf-8' }),
|
|
207
|
+
body: JSON.stringify({ path }),
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
throw new Error(`Failed to list files for ${path}: HTTP ${res.status}`);
|
|
211
|
+
}
|
|
212
|
+
const payload = await res.json();
|
|
213
|
+
return (payload.files ?? []).map((f) => ({
|
|
214
|
+
name: f.name,
|
|
215
|
+
path: f.path,
|
|
216
|
+
size: f.size,
|
|
217
|
+
modifiedTime: f.modifiedTime,
|
|
218
|
+
etag: f.etag,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
async listFolders(path) {
|
|
222
|
+
const res = await fetch(this.ioUrl('/list-folders'), {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: this.headers({ 'Content-Type': 'application/json; charset=utf-8' }),
|
|
225
|
+
body: JSON.stringify({ path }),
|
|
226
|
+
});
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
throw new Error(`Failed to list folders for ${path}: HTTP ${res.status}`);
|
|
229
|
+
}
|
|
230
|
+
const payload = await res.json();
|
|
231
|
+
return payload.folders ?? [];
|
|
232
|
+
}
|
|
233
|
+
async readFile(path) {
|
|
234
|
+
const res = await fetch(this.fileUrl(path), {
|
|
235
|
+
method: 'GET',
|
|
236
|
+
headers: this.headers(),
|
|
237
|
+
});
|
|
238
|
+
if (!res.ok) {
|
|
239
|
+
throw new Error(`Failed to read ${path}: HTTP ${res.status}`);
|
|
240
|
+
}
|
|
241
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
242
|
+
}
|
|
243
|
+
async writeFile(path, data) {
|
|
244
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
245
|
+
const res = await fetch(this.fileUrl(path), {
|
|
246
|
+
method: 'PUT',
|
|
247
|
+
headers: this.headers({ 'Content-Type': 'application/octet-stream' }),
|
|
248
|
+
body: bytes,
|
|
249
|
+
});
|
|
250
|
+
if (!res.ok && res.status !== 201 && res.status !== 204) {
|
|
251
|
+
throw new Error(`Failed to write ${path}: HTTP ${res.status}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async deleteFile(path) {
|
|
255
|
+
const res = await fetch(this.fileUrl(path), {
|
|
256
|
+
method: 'DELETE',
|
|
257
|
+
headers: this.headers(),
|
|
258
|
+
});
|
|
259
|
+
if (!res.ok && res.status !== 404 && res.status !== 405) {
|
|
260
|
+
throw new Error(`Failed to delete ${path}: HTTP ${res.status}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async getFileMetadata(path) {
|
|
264
|
+
const res = await fetch(this.ioUrl('/metadata'), {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: this.headers({ 'Content-Type': 'application/json; charset=utf-8' }),
|
|
267
|
+
body: JSON.stringify({ path }),
|
|
268
|
+
});
|
|
269
|
+
if (res.status === 404)
|
|
270
|
+
return null;
|
|
271
|
+
if (!res.ok)
|
|
272
|
+
return null;
|
|
273
|
+
const payload = await res.json();
|
|
274
|
+
const f = payload.file;
|
|
275
|
+
if (!f)
|
|
276
|
+
return null;
|
|
277
|
+
return {
|
|
278
|
+
name: f.name,
|
|
279
|
+
path: f.path,
|
|
280
|
+
size: f.size,
|
|
281
|
+
modifiedTime: f.modifiedTime,
|
|
282
|
+
etag: f.etag,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async putStoredFile(path, data, options = {}) {
|
|
286
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
287
|
+
const res = await fetch(this.storedFileUrl(path), {
|
|
288
|
+
method: 'PUT',
|
|
289
|
+
headers: this.headers({
|
|
290
|
+
'Content-Type': options.contentType || 'application/octet-stream',
|
|
291
|
+
'X-Interocitor-Device-Id': options.uploadedByDeviceId || '',
|
|
292
|
+
'X-Interocitor-Plaintext-Size': String(options.plaintextSize ?? bytes.byteLength),
|
|
293
|
+
}),
|
|
294
|
+
body: bytes,
|
|
295
|
+
});
|
|
296
|
+
if (!res.ok)
|
|
297
|
+
throw new Error(`Failed to upload stored file ${path}: HTTP ${res.status}`);
|
|
298
|
+
const payload = await res.json();
|
|
299
|
+
return payload.file;
|
|
300
|
+
}
|
|
301
|
+
async getStoredFile(path) {
|
|
302
|
+
const res = await fetch(this.storedFileUrl(path), { method: 'GET', headers: this.headers() });
|
|
303
|
+
if (!res.ok)
|
|
304
|
+
throw new Error(`Failed to read stored file ${path}: HTTP ${res.status}`);
|
|
305
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
306
|
+
}
|
|
307
|
+
async deleteStoredFile(path) {
|
|
308
|
+
const res = await fetch(this.storedFileUrl(path), { method: 'DELETE', headers: this.headers() });
|
|
309
|
+
if (!res.ok && res.status !== 404)
|
|
310
|
+
throw new Error(`Failed to delete stored file ${path}: HTTP ${res.status}`);
|
|
311
|
+
}
|
|
312
|
+
async getStoredFileMetadata(path) {
|
|
313
|
+
const res = await fetch(this.ioUrl('/stored-file-metadata'), {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: this.headers({ 'Content-Type': 'application/json; charset=utf-8' }),
|
|
316
|
+
body: JSON.stringify({ path }),
|
|
317
|
+
});
|
|
318
|
+
if (res.status === 404)
|
|
319
|
+
return null;
|
|
320
|
+
if (!res.ok)
|
|
321
|
+
return null;
|
|
322
|
+
const payload = await res.json();
|
|
323
|
+
return payload.file ?? null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Drive Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Uses Google Drive API v3 via OAuth2 with `drive.file` scope.
|
|
5
|
+
* The app can only see files it created or the user explicitly shared.
|
|
6
|
+
*
|
|
7
|
+
* Requires: Google API client ID from Google Cloud Console.
|
|
8
|
+
* Auth flow: Google Identity Services (GIS) popup or redirect.
|
|
9
|
+
*/
|
|
10
|
+
import type { StorageAdapter, FileEntry } from '../core/types.ts';
|
|
11
|
+
interface GoogleDriveConfig {
|
|
12
|
+
clientId: string;
|
|
13
|
+
/** Redirect URI for OAuth (default: current origin) */
|
|
14
|
+
redirectUri?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Google Drive adapter using the browser OAuth token flow.
|
|
18
|
+
*
|
|
19
|
+
* This is the easiest zero-infrastructure option when your users already live
|
|
20
|
+
* in Google Workspace or personal Drive.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const adapter = new GoogleDriveAdapter({ clientId: 'YOUR_GOOGLE_CLIENT_ID' });
|
|
25
|
+
* const engine = new Interocitor(adapter, { remotePath: '/MyApp' });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare class GoogleDriveAdapter implements StorageAdapter {
|
|
29
|
+
readonly name = "google-drive";
|
|
30
|
+
private config;
|
|
31
|
+
private accessToken;
|
|
32
|
+
private fileIdCache;
|
|
33
|
+
private folderIdCache;
|
|
34
|
+
constructor(config: GoogleDriveConfig);
|
|
35
|
+
authenticate(): Promise<void>;
|
|
36
|
+
/** Set token directly (for apps that handle their own OAuth flow). */
|
|
37
|
+
setAccessToken(token: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Returns the Google OAuth clientId for embedding in a QR payload.
|
|
40
|
+
* The scanner uses this to configure their GoogleDriveAdapter.
|
|
41
|
+
* The OAuth flow (and resulting access token) is performed separately by the user.
|
|
42
|
+
*/
|
|
43
|
+
getHandshakeConfig(): string;
|
|
44
|
+
isAuthenticated(): boolean;
|
|
45
|
+
private verifyToken;
|
|
46
|
+
private headers;
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a path like "/Interocitor/changes/dev_abc.ndjson"
|
|
49
|
+
* to a Google Drive file ID by walking the folder tree.
|
|
50
|
+
*/
|
|
51
|
+
private resolveFileId;
|
|
52
|
+
private resolveFolderId;
|
|
53
|
+
ensureFolder(path: string): Promise<void>;
|
|
54
|
+
listFiles(folderPath: string): Promise<FileEntry[]>;
|
|
55
|
+
listFolders(folderPath: string): Promise<string[]>;
|
|
56
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
57
|
+
writeFile(path: string, data: Uint8Array | string): Promise<void>;
|
|
58
|
+
deleteFile(path: string): Promise<void>;
|
|
59
|
+
getFileMetadata(path: string): Promise<FileEntry | null>;
|
|
60
|
+
/** Clear caches (useful after compaction deletes files). */
|
|
61
|
+
clearCache(): void;
|
|
62
|
+
}
|
|
63
|
+
export {};
|
|
64
|
+
//# sourceMappingURL=google-drive.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google-drive.d.ts","sourceRoot":"","sources":["../../src/adapters/google-drive.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAMlE,UAAU,iBAAiB;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,kBAAmB,YAAW,cAAc;IACvD,QAAQ,CAAC,IAAI,kBAAkB;IAE/B,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,WAAW,CAAuB;IAG1C,OAAO,CAAC,WAAW,CAAkC;IACrD,OAAO,CAAC,aAAa,CAAkC;gBAE3C,MAAM,EAAE,iBAAiB;IAM/B,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAkDnC,sEAAsE;IACtE,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKnC;;;;OAIG;IACH,kBAAkB,IAAI,MAAM;IAI5B,eAAe,IAAI,OAAO;YAIZ,WAAW;IAWzB,OAAO,CAAC,OAAO;IAOf;;;OAGG;YACW,aAAa;YAyCb,eAAe;IAiCvB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6CzC,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAwBnD,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAclD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAc3C,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA4EjE,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYvC,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAoB9D,4DAA4D;IAC5D,UAAU,IAAI,IAAI;CAInB"}
|