@open-webapp/drive-sync 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 +41 -0
- package/SPEC.md +150 -0
- package/dist/broadcast.d.ts +15 -0
- package/dist/broadcast.js +38 -0
- package/dist/connection.d.ts +84 -0
- package/dist/connection.js +90 -0
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +84 -0
- package/dist/files.d.ts +70 -0
- package/dist/files.js +231 -0
- package/dist/gis.d.ts +8 -0
- package/dist/gis.js +33 -0
- package/dist/http.d.ts +32 -0
- package/dist/http.js +188 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +207 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +6 -0
- package/dist/permissions.d.ts +37 -0
- package/dist/permissions.js +71 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.js +9 -0
- package/dist/reconcile.d.ts +12 -0
- package/dist/reconcile.js +45 -0
- package/dist/refresh.d.ts +34 -0
- package/dist/refresh.js +89 -0
- package/dist/storage.d.ts +27 -0
- package/dist/storage.js +70 -0
- package/dist/testing/driveFake.d.ts +50 -0
- package/dist/testing/driveFake.js +423 -0
- package/dist/testing/gisFake.d.ts +60 -0
- package/dist/testing/gisFake.js +86 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +2 -0
- package/dist/token.d.ts +54 -0
- package/dist/token.js +140 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +1 -0
- package/package.json +40 -0
package/dist/files.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { driveFetch } from './http.js';
|
|
2
|
+
import { escapeQ } from './query.js';
|
|
3
|
+
import { NotFoundError } from './errors.js';
|
|
4
|
+
const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
|
|
5
|
+
const UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3';
|
|
6
|
+
const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder';
|
|
7
|
+
/** Scopes this library always requests/requires. */
|
|
8
|
+
export const REQUIRED_SCOPES = [
|
|
9
|
+
'https://www.googleapis.com/auth/drive.file',
|
|
10
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* Fetches a file's content. Returns `null` on a 404 rather than throwing,
|
|
14
|
+
* since Drive 404s both for a genuinely wrong id and for a file the
|
|
15
|
+
* connected account cannot see — this ambiguity is documented in the public
|
|
16
|
+
* API and callers are expected to treat `null` as "not available" rather
|
|
17
|
+
* than distinguishing the two cases.
|
|
18
|
+
*/
|
|
19
|
+
export async function read(opts) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await driveFetch({
|
|
22
|
+
appId: opts.appId,
|
|
23
|
+
projectId: opts.projectId,
|
|
24
|
+
clientId: opts.clientId,
|
|
25
|
+
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}?alt=media`,
|
|
26
|
+
method: 'GET',
|
|
27
|
+
interactive: opts.interactive,
|
|
28
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
29
|
+
logger: opts.logger,
|
|
30
|
+
fetchEmail: opts.fetchEmail,
|
|
31
|
+
});
|
|
32
|
+
const contentType = res.headers.get('Content-Type') ?? '';
|
|
33
|
+
const isTextual = contentType === '' ||
|
|
34
|
+
contentType.startsWith('text/') ||
|
|
35
|
+
contentType.includes('application/json');
|
|
36
|
+
return isTextual ? await res.text() : await res.blob();
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof NotFoundError) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function remove(opts) {
|
|
46
|
+
await driveFetch({
|
|
47
|
+
appId: opts.appId,
|
|
48
|
+
projectId: opts.projectId,
|
|
49
|
+
clientId: opts.clientId,
|
|
50
|
+
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}`,
|
|
51
|
+
method: 'DELETE',
|
|
52
|
+
interactive: opts.interactive,
|
|
53
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
54
|
+
logger: opts.logger,
|
|
55
|
+
fetchEmail: opts.fetchEmail,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function toBlob(content, mimeType) {
|
|
59
|
+
return content instanceof Blob ? content : new Blob([content], { type: mimeType });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates or updates a file's content. Updates (fileId provided) use the
|
|
63
|
+
* media-upload endpoint with the raw body. Creates use a `FormData`
|
|
64
|
+
* multipart body — FormData + fetch computes and inserts its own boundary,
|
|
65
|
+
* so file content that happens to contain a literal boundary-looking string
|
|
66
|
+
* can never corrupt the request (a known bug in a hand-rolled-boundary
|
|
67
|
+
* implementation this replaces).
|
|
68
|
+
*/
|
|
69
|
+
export async function write(opts) {
|
|
70
|
+
if (opts.fileId) {
|
|
71
|
+
const res = await driveFetch({
|
|
72
|
+
appId: opts.appId,
|
|
73
|
+
projectId: opts.projectId,
|
|
74
|
+
clientId: opts.clientId,
|
|
75
|
+
url: `${UPLOAD_BASE}/files/${encodeURIComponent(opts.fileId)}?uploadType=media`,
|
|
76
|
+
method: 'PATCH',
|
|
77
|
+
headers: { 'Content-Type': opts.mimeType },
|
|
78
|
+
body: opts.content,
|
|
79
|
+
interactive: opts.interactive,
|
|
80
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
81
|
+
logger: opts.logger,
|
|
82
|
+
fetchEmail: opts.fetchEmail,
|
|
83
|
+
});
|
|
84
|
+
const json = (await res.json());
|
|
85
|
+
return { id: json.id, name: json.name ?? opts.name };
|
|
86
|
+
}
|
|
87
|
+
const metadata = {
|
|
88
|
+
name: opts.name,
|
|
89
|
+
mimeType: opts.mimeType,
|
|
90
|
+
parents: opts.folderId ? [opts.folderId] : undefined,
|
|
91
|
+
};
|
|
92
|
+
const form = new FormData();
|
|
93
|
+
form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
|
94
|
+
form.append('media', toBlob(opts.content, opts.mimeType));
|
|
95
|
+
// Let the Fetch API's own Request serialize the FormData: this computes
|
|
96
|
+
// and inserts a correct multipart boundary (never hand-rolled by us) and
|
|
97
|
+
// exposes the resulting `Content-Type: multipart/form-data; boundary=...`
|
|
98
|
+
// header, which we then forward explicitly alongside the serialized body.
|
|
99
|
+
// This also makes the request body a plain string by the time it reaches
|
|
100
|
+
// driveFetch, so environments (e.g. test fakes) that read the body via
|
|
101
|
+
// `.text()` rather than re-driving a real network stack see the fully
|
|
102
|
+
// encoded multipart payload rather than an opaque FormData object.
|
|
103
|
+
const serialized = new Request('https://example.invalid/', { method: 'POST', body: form });
|
|
104
|
+
const multipartContentType = serialized.headers.get('content-type') ?? undefined;
|
|
105
|
+
const multipartBody = await serialized.text();
|
|
106
|
+
const res = await driveFetch({
|
|
107
|
+
appId: opts.appId,
|
|
108
|
+
projectId: opts.projectId,
|
|
109
|
+
clientId: opts.clientId,
|
|
110
|
+
url: `${UPLOAD_BASE}/files?uploadType=multipart&fields=id`,
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: multipartContentType ? { 'Content-Type': multipartContentType } : undefined,
|
|
113
|
+
body: multipartBody,
|
|
114
|
+
interactive: opts.interactive,
|
|
115
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
116
|
+
logger: opts.logger,
|
|
117
|
+
fetchEmail: opts.fetchEmail,
|
|
118
|
+
});
|
|
119
|
+
const json = (await res.json());
|
|
120
|
+
return { id: json.id, name: opts.name };
|
|
121
|
+
}
|
|
122
|
+
function buildQuery(opts) {
|
|
123
|
+
const clauses = [];
|
|
124
|
+
if (opts.folderId) {
|
|
125
|
+
clauses.push(`'${escapeQ(opts.folderId)}' in parents`);
|
|
126
|
+
}
|
|
127
|
+
if (opts.mimeType) {
|
|
128
|
+
clauses.push(`mimeType='${escapeQ(opts.mimeType)}'`);
|
|
129
|
+
}
|
|
130
|
+
if (opts.nameEquals) {
|
|
131
|
+
clauses.push(`name='${escapeQ(opts.nameEquals)}'`);
|
|
132
|
+
}
|
|
133
|
+
clauses.push('trashed=false');
|
|
134
|
+
return clauses.join(' and ');
|
|
135
|
+
}
|
|
136
|
+
export async function list(opts) {
|
|
137
|
+
const q = buildQuery(opts);
|
|
138
|
+
const url = `${DRIVE_BASE}/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent('files(id,name,mimeType)')}`;
|
|
139
|
+
const res = await driveFetch({
|
|
140
|
+
appId: opts.appId,
|
|
141
|
+
projectId: opts.projectId,
|
|
142
|
+
clientId: opts.clientId,
|
|
143
|
+
url,
|
|
144
|
+
method: 'GET',
|
|
145
|
+
interactive: opts.interactive,
|
|
146
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
147
|
+
logger: opts.logger,
|
|
148
|
+
fetchEmail: opts.fetchEmail,
|
|
149
|
+
});
|
|
150
|
+
const json = (await res.json());
|
|
151
|
+
return json.files ?? [];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Creates a plain-JSON folder (no content, so this goes through the
|
|
155
|
+
* non-upload /files endpoint) and returns its id.
|
|
156
|
+
*/
|
|
157
|
+
async function createFolder(opts) {
|
|
158
|
+
const res = await driveFetch({
|
|
159
|
+
appId: opts.appId,
|
|
160
|
+
projectId: opts.projectId,
|
|
161
|
+
clientId: opts.clientId,
|
|
162
|
+
url: `${DRIVE_BASE}/files?fields=id`,
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'Content-Type': 'application/json' },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
name: opts.name,
|
|
167
|
+
mimeType: FOLDER_MIME_TYPE,
|
|
168
|
+
parents: opts.parentId ? [opts.parentId] : undefined,
|
|
169
|
+
}),
|
|
170
|
+
interactive: opts.interactive,
|
|
171
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
172
|
+
logger: opts.logger,
|
|
173
|
+
fetchEmail: opts.fetchEmail,
|
|
174
|
+
});
|
|
175
|
+
const json = (await res.json());
|
|
176
|
+
return json.id;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Walks `folderPath` level by level, creating any missing folder along the
|
|
180
|
+
* way, and returns the leaf folder's id (never persisted by this library —
|
|
181
|
+
* callers get it fresh on every call).
|
|
182
|
+
*
|
|
183
|
+
* Root-level (first path segment) lookup design choice: Drive's "My Drive"
|
|
184
|
+
* root has no single well-known parent id that is safe to assume across
|
|
185
|
+
* every account/Shared-Drive configuration, and this library only has the
|
|
186
|
+
* `drive.file` scope (which only sees files/folders the app itself created
|
|
187
|
+
* or that were explicitly shared with it) — so instead of trying to anchor
|
|
188
|
+
* the first level under an assumed parent, we search WITHOUT a `... in
|
|
189
|
+
* parents` clause for the first level (`mimeType='...folder' and name='...'
|
|
190
|
+
* and trashed=false`), which finds the folder anywhere this app can see it.
|
|
191
|
+
* Every subsequent level uses the previous level's resolved id as an
|
|
192
|
+
* explicit `in parents` clause, which is unambiguous.
|
|
193
|
+
*/
|
|
194
|
+
export async function ensureFolderPath(opts) {
|
|
195
|
+
let parentId;
|
|
196
|
+
for (const name of opts.folderPath) {
|
|
197
|
+
const existing = await list({
|
|
198
|
+
appId: opts.appId,
|
|
199
|
+
projectId: opts.projectId,
|
|
200
|
+
clientId: opts.clientId,
|
|
201
|
+
interactive: opts.interactive,
|
|
202
|
+
logger: opts.logger,
|
|
203
|
+
fetchEmail: opts.fetchEmail,
|
|
204
|
+
folderId: parentId,
|
|
205
|
+
mimeType: FOLDER_MIME_TYPE,
|
|
206
|
+
nameEquals: name,
|
|
207
|
+
});
|
|
208
|
+
if (existing.length > 0) {
|
|
209
|
+
parentId = existing[0].id;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
parentId = await createFolder({
|
|
213
|
+
appId: opts.appId,
|
|
214
|
+
projectId: opts.projectId,
|
|
215
|
+
clientId: opts.clientId,
|
|
216
|
+
interactive: opts.interactive,
|
|
217
|
+
logger: opts.logger,
|
|
218
|
+
fetchEmail: opts.fetchEmail,
|
|
219
|
+
name,
|
|
220
|
+
parentId,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (!parentId) {
|
|
224
|
+
// folderPath was empty — nothing to resolve. This should not happen for
|
|
225
|
+
// a correctly-configured factory (folderPath is required and expected
|
|
226
|
+
// to be non-empty), but guard rather than returning `undefined` as a
|
|
227
|
+
// string.
|
|
228
|
+
throw new Error('ensureFolderPath: folderPath must contain at least one segment');
|
|
229
|
+
}
|
|
230
|
+
return parentId;
|
|
231
|
+
}
|
package/dist/gis.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves once `window.google.accounts.oauth2.initTokenClient` becomes
|
|
4
|
+
* available, polling every 100ms. Rejects with a GisLoadError if it does not
|
|
5
|
+
* become available within 10 seconds. Uses real timers so this behaves
|
|
6
|
+
* correctly under `vi.useFakeTimers()`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function waitForGoogleIdentityServices(logger?: Logger): Promise<void>;
|
package/dist/gis.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { GisLoadError } from './errors.js';
|
|
2
|
+
const POLL_INTERVAL_MS = 100;
|
|
3
|
+
const TIMEOUT_MS = 10_000;
|
|
4
|
+
function isGisAvailable() {
|
|
5
|
+
const w = globalThis;
|
|
6
|
+
return typeof w.google?.accounts?.oauth2?.initTokenClient !== 'undefined';
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Resolves once `window.google.accounts.oauth2.initTokenClient` becomes
|
|
10
|
+
* available, polling every 100ms. Rejects with a GisLoadError if it does not
|
|
11
|
+
* become available within 10 seconds. Uses real timers so this behaves
|
|
12
|
+
* correctly under `vi.useFakeTimers()`.
|
|
13
|
+
*/
|
|
14
|
+
export function waitForGoogleIdentityServices(logger) {
|
|
15
|
+
if (isGisAvailable()) {
|
|
16
|
+
return Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
const interval = setInterval(() => {
|
|
21
|
+
logger?.debug('drive-sync: polling for Google Identity Services...');
|
|
22
|
+
if (isGisAvailable()) {
|
|
23
|
+
clearInterval(interval);
|
|
24
|
+
resolve();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (Date.now() - startedAt >= TIMEOUT_MS) {
|
|
28
|
+
clearInterval(interval);
|
|
29
|
+
reject(new GisLoadError());
|
|
30
|
+
}
|
|
31
|
+
}, POLL_INTERVAL_MS);
|
|
32
|
+
});
|
|
33
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
export interface DriveFetchOptions {
|
|
3
|
+
appId: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
url: string;
|
|
7
|
+
method?: string;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
body?: BodyInit;
|
|
10
|
+
interactive?: boolean;
|
|
11
|
+
requiredScopes: string[];
|
|
12
|
+
logger?: Logger;
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the connected account's email from a fresh access token. When
|
|
15
|
+
* supplied (index.ts always wires the real implementation in), a 401's
|
|
16
|
+
* silent-refresh-and-retry goes through `refreshSilently` (connection.ts)
|
|
17
|
+
* instead of a bare `acquireToken`, so a token GIS silently hands back for
|
|
18
|
+
* the WRONG Google account is detected and surfaces as a typed
|
|
19
|
+
* `WrongAccountError` rather than being used transparently. Optional so
|
|
20
|
+
* this module has no hard dependency on a network implementation.
|
|
21
|
+
*/
|
|
22
|
+
fetchEmail?: (accessToken: string) => Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Single entry point for all Drive API HTTP calls. Handles token
|
|
26
|
+
* acquisition/attachment, 429/5xx retry with backoff, 401 silent-refresh-and-
|
|
27
|
+
* retry-once, and 403/404 error normalization. Every response is checked for
|
|
28
|
+
* `.ok` before any body is parsed by this function or its callers — callers
|
|
29
|
+
* (files.ts, permissions.ts) receive the raw Response on success and must
|
|
30
|
+
* call .json()/.text()/.blob() themselves.
|
|
31
|
+
*/
|
|
32
|
+
export declare function driveFetch(opts: DriveFetchOptions): Promise<Response>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { acquireToken } from './token.js';
|
|
2
|
+
import { getConnection, refreshSilently } from './connection.js';
|
|
3
|
+
import { clearToken } from './storage.js';
|
|
4
|
+
import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
|
|
5
|
+
const MAX_ATTEMPTS = 3;
|
|
6
|
+
const BASE_DELAY_MS = 500;
|
|
7
|
+
function isRetryableStatus(status) {
|
|
8
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
9
|
+
}
|
|
10
|
+
/** Parses a `Retry-After` header (seconds, per HTTP spec) into milliseconds. */
|
|
11
|
+
function retryAfterMs(res) {
|
|
12
|
+
const header = res.headers.get('Retry-After');
|
|
13
|
+
if (!header)
|
|
14
|
+
return undefined;
|
|
15
|
+
const seconds = Number(header);
|
|
16
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
return seconds * 1000;
|
|
19
|
+
}
|
|
20
|
+
function retryAfterSeconds(res) {
|
|
21
|
+
const header = res.headers.get('Retry-After');
|
|
22
|
+
if (!header)
|
|
23
|
+
return undefined;
|
|
24
|
+
const seconds = Number(header);
|
|
25
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
26
|
+
return undefined;
|
|
27
|
+
return seconds;
|
|
28
|
+
}
|
|
29
|
+
async function sleep(ms) {
|
|
30
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Single entry point for all Drive API HTTP calls. Handles token
|
|
34
|
+
* acquisition/attachment, 429/5xx retry with backoff, 401 silent-refresh-and-
|
|
35
|
+
* retry-once, and 403/404 error normalization. Every response is checked for
|
|
36
|
+
* `.ok` before any body is parsed by this function or its callers — callers
|
|
37
|
+
* (files.ts, permissions.ts) receive the raw Response on success and must
|
|
38
|
+
* call .json()/.text()/.blob() themselves.
|
|
39
|
+
*/
|
|
40
|
+
export async function driveFetch(opts) {
|
|
41
|
+
const { appId, projectId, clientId, requiredScopes, logger } = opts;
|
|
42
|
+
const interactive = !!opts.interactive;
|
|
43
|
+
// Resolve a hint email from the current connection (if any) so a
|
|
44
|
+
// non-interactive silent refresh can target the right account. If there is
|
|
45
|
+
// no connection at all and interactive is false, acquireToken's silent
|
|
46
|
+
// request (hint: undefined) will fail on GIS's side and surface as
|
|
47
|
+
// NeedsReauthError from acquireToken itself — we deliberately let that
|
|
48
|
+
// propagate rather than pre-emptively short-circuiting here, since
|
|
49
|
+
// acquireToken/GIS is the single source of truth for "can we get a token".
|
|
50
|
+
const conn = await getConnection({ appId, projectId, requiredScopes });
|
|
51
|
+
const hint = conn?.email;
|
|
52
|
+
const token = await acquireToken({
|
|
53
|
+
appId,
|
|
54
|
+
projectId,
|
|
55
|
+
clientId,
|
|
56
|
+
scopes: requiredScopes,
|
|
57
|
+
interactive,
|
|
58
|
+
hint,
|
|
59
|
+
logger,
|
|
60
|
+
});
|
|
61
|
+
return performFetch(opts, token.accessToken, /* isRetryAfter401 */ false);
|
|
62
|
+
}
|
|
63
|
+
async function performFetch(opts, accessToken, isRetryAfter401) {
|
|
64
|
+
const { appId, projectId, clientId, url, method, body, requiredScopes, logger } = opts;
|
|
65
|
+
const interactive = !!opts.interactive;
|
|
66
|
+
const headers = {
|
|
67
|
+
...opts.headers,
|
|
68
|
+
Authorization: `Bearer ${accessToken}`,
|
|
69
|
+
};
|
|
70
|
+
let res;
|
|
71
|
+
let lastBodyText = '';
|
|
72
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
73
|
+
res = await fetch(url, { method, headers, body });
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
return res;
|
|
76
|
+
}
|
|
77
|
+
if (isRetryableStatus(res.status) && attempt < MAX_ATTEMPTS) {
|
|
78
|
+
const retryAfter = retryAfterMs(res);
|
|
79
|
+
const delayMs = retryAfter ?? BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
80
|
+
logger?.warn('drive-sync: retrying Drive request', {
|
|
81
|
+
status: res.status,
|
|
82
|
+
attempt,
|
|
83
|
+
delayMs,
|
|
84
|
+
});
|
|
85
|
+
await sleep(delayMs);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Either not retryable, or attempts exhausted — fall through to
|
|
89
|
+
// status-specific handling below using this final response.
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
// res is guaranteed to be set (loop runs at least once).
|
|
93
|
+
const finalRes = res;
|
|
94
|
+
if (finalRes.ok) {
|
|
95
|
+
return finalRes;
|
|
96
|
+
}
|
|
97
|
+
lastBodyText = await finalRes.text();
|
|
98
|
+
if (isRetryableStatus(finalRes.status)) {
|
|
99
|
+
if (finalRes.status === 429) {
|
|
100
|
+
throw new RateLimitedError({
|
|
101
|
+
status: 429,
|
|
102
|
+
reason: lastBodyText,
|
|
103
|
+
retryAfter: retryAfterSeconds(finalRes),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
throw new TransientError(`Drive request failed with status ${finalRes.status}: ${lastBodyText}`, { status: finalRes.status, reason: lastBodyText });
|
|
107
|
+
}
|
|
108
|
+
if (finalRes.status === 401) {
|
|
109
|
+
if (isRetryAfter401 || interactive) {
|
|
110
|
+
// Either this IS the retry attempt (already tried a silent refresh
|
|
111
|
+
// once and it still 401'd), or the original call was interactive
|
|
112
|
+
// (caller is expected to have already gone through a fresh
|
|
113
|
+
// connect()-style consent flow) — either way, do not attempt another
|
|
114
|
+
// refresh; surface as NeedsReauthError.
|
|
115
|
+
throw new NeedsReauthError('Drive request unauthorized (401)', {
|
|
116
|
+
status: 401,
|
|
117
|
+
reason: lastBodyText,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
await clearToken(appId, projectId);
|
|
121
|
+
let refreshed;
|
|
122
|
+
try {
|
|
123
|
+
// Re-read the connection to find the email a verified silent refresh
|
|
124
|
+
// must land on. Purely a local storage read — no network call.
|
|
125
|
+
const conn = await getConnection({ appId, projectId, requiredScopes });
|
|
126
|
+
if (conn?.email && opts.fetchEmail) {
|
|
127
|
+
refreshed = await refreshSilently({
|
|
128
|
+
appId,
|
|
129
|
+
projectId,
|
|
130
|
+
clientId,
|
|
131
|
+
scopes: requiredScopes,
|
|
132
|
+
expectedEmail: conn.email,
|
|
133
|
+
fetchEmail: opts.fetchEmail,
|
|
134
|
+
logger,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
refreshed = await acquireToken({
|
|
139
|
+
appId,
|
|
140
|
+
projectId,
|
|
141
|
+
clientId,
|
|
142
|
+
scopes: requiredScopes,
|
|
143
|
+
interactive: false,
|
|
144
|
+
hint: conn?.email,
|
|
145
|
+
logger,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (err instanceof WrongAccountError) {
|
|
151
|
+
// Not a plain "can't get a token" failure — surface as-is so the
|
|
152
|
+
// caller knows a DIFFERENT account was silently authenticated,
|
|
153
|
+
// rather than masking it behind a generic reauth error. The bad
|
|
154
|
+
// token was already cleared by refreshSilently.
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
throw new NeedsReauthError('Drive request unauthorized (401); silent refresh failed', {
|
|
158
|
+
status: 401,
|
|
159
|
+
reason: lastBodyText,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return performFetch(opts, refreshed.accessToken, /* isRetryAfter401 */ true);
|
|
163
|
+
}
|
|
164
|
+
if (finalRes.status === 403) {
|
|
165
|
+
if (lastBodyText.includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT')) {
|
|
166
|
+
await clearToken(appId, projectId);
|
|
167
|
+
throw new ScopeInsufficientError('Access token has insufficient scope', {
|
|
168
|
+
status: 403,
|
|
169
|
+
reason: lastBodyText,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
throw new DriveSyncError(`Drive request forbidden (403): ${lastBodyText}`, {
|
|
173
|
+
status: 403,
|
|
174
|
+
reason: lastBodyText,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (finalRes.status === 404) {
|
|
178
|
+
// No fileId is reliably available at this layer (the URL may be a
|
|
179
|
+
// search/list/upload endpoint rather than a single-file get); callers
|
|
180
|
+
// that have a fileId in scope should catch this generic 404 and
|
|
181
|
+
// translate it into a NotFoundError with the fileId attached. We throw
|
|
182
|
+
// NotFoundError here with an empty fileId as a reasonable default so a
|
|
183
|
+
// raw un-typed error never surfaces, but files.ts/permissions.ts are
|
|
184
|
+
// expected to re-throw with the concrete fileId when they have one.
|
|
185
|
+
throw new NotFoundError('', { status: 404, reason: lastBodyText });
|
|
186
|
+
}
|
|
187
|
+
throw new DriveSyncError(`Drive request failed with status ${finalRes.status}: ${lastBodyText}`, { status: finalRes.status, reason: lastBodyText });
|
|
188
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CallOptions, Connection, DriveSyncOptions, DrivePermission, FileRef } from './types.js';
|
|
2
|
+
export type { DriveSyncOptions, Connection, StoredToken, FileRef, DrivePermission, CallOptions } from './types.js';
|
|
3
|
+
export * from './errors.js';
|
|
4
|
+
export interface FilesHandle {
|
|
5
|
+
list(opts?: {
|
|
6
|
+
folderId?: string;
|
|
7
|
+
mimeType?: string;
|
|
8
|
+
nameEquals?: string;
|
|
9
|
+
}, callOpts?: CallOptions): Promise<FileRef[]>;
|
|
10
|
+
read(fileId: string, callOpts?: CallOptions): Promise<string | Blob | null>;
|
|
11
|
+
write(opts: {
|
|
12
|
+
fileId?: string;
|
|
13
|
+
folderId?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
content: string | Blob;
|
|
16
|
+
mimeType: string;
|
|
17
|
+
}, callOpts?: CallOptions): Promise<FileRef>;
|
|
18
|
+
remove(fileId: string, callOpts?: CallOptions): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface PermissionsHandle {
|
|
21
|
+
list(fileId: string, callOpts?: CallOptions): Promise<DrivePermission[]>;
|
|
22
|
+
grant(opts: {
|
|
23
|
+
fileId: string;
|
|
24
|
+
type: 'user' | 'anyone';
|
|
25
|
+
role: string;
|
|
26
|
+
emailAddress?: string;
|
|
27
|
+
}, callOpts?: CallOptions): Promise<DrivePermission>;
|
|
28
|
+
update(opts: {
|
|
29
|
+
fileId: string;
|
|
30
|
+
permissionId: string;
|
|
31
|
+
role: string;
|
|
32
|
+
}, callOpts?: CallOptions): Promise<DrivePermission>;
|
|
33
|
+
revoke(opts: {
|
|
34
|
+
fileId: string;
|
|
35
|
+
permissionId: string;
|
|
36
|
+
}, callOpts?: CallOptions): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface ProjectHandle {
|
|
39
|
+
connect(): Promise<Connection>;
|
|
40
|
+
getConnection(): Promise<Connection | null>;
|
|
41
|
+
disconnect(): Promise<void>;
|
|
42
|
+
ensureFolderPath(): Promise<string>;
|
|
43
|
+
files: FilesHandle;
|
|
44
|
+
permissions: PermissionsHandle;
|
|
45
|
+
}
|
|
46
|
+
export interface DriveSync {
|
|
47
|
+
activate(): () => void;
|
|
48
|
+
reconcile(knownProjectIds: string[]): Promise<void>;
|
|
49
|
+
dropProject(projectId: string): Promise<void>;
|
|
50
|
+
project(projectId: string): ProjectHandle;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Creates the drive-sync facade. `createDriveSync` itself attaches NO
|
|
54
|
+
* listeners and makes NO network calls — everything is deferred until the
|
|
55
|
+
* returned object's methods are actually called.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createDriveSync(options: DriveSyncOptions): DriveSync;
|