@open-webapp/drive-sync 0.5.3 → 0.5.5
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/dist/connection.js +11 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +13 -0
- package/dist/files.d.ts +3 -1
- package/dist/files.js +5 -3
- package/dist/http.js +7 -1
- package/dist/index.js +5 -1
- package/dist/token.js +45 -20
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -9,12 +9,19 @@ const TOKEN_REUSE_BUFFER_MS = 5 * 60 * 1000;
|
|
|
9
9
|
* resolves the account email, and persists the durable Connection record.
|
|
10
10
|
*/
|
|
11
11
|
export async function connect(opts) {
|
|
12
|
+
// On a re-auth the previous connection's email is the account the user is
|
|
13
|
+
// expected to consent as again; pass it as a hint so the popup_closed
|
|
14
|
+
// recovery probe (token.ts) can resolve a completed grant silently even
|
|
15
|
+
// when the browser holds several Google sessions. First-time connect has
|
|
16
|
+
// no prior email and simply passes undefined.
|
|
17
|
+
const existing = await getConn(opts.appId, opts.projectId);
|
|
12
18
|
const token = await acquireToken({
|
|
13
19
|
appId: opts.appId,
|
|
14
20
|
projectId: opts.projectId,
|
|
15
21
|
clientId: opts.clientId,
|
|
16
22
|
scopes: opts.scopes,
|
|
17
23
|
interactive: true,
|
|
24
|
+
hint: existing?.email,
|
|
18
25
|
logger: opts.logger,
|
|
19
26
|
});
|
|
20
27
|
const email = await opts.fetchEmail(token.accessToken);
|
|
@@ -95,12 +102,16 @@ export async function getAccessToken(opts) {
|
|
|
95
102
|
if (cached && cached.expiresAt > Date.now() + TOKEN_REUSE_BUFFER_MS) {
|
|
96
103
|
return cached.accessToken;
|
|
97
104
|
}
|
|
105
|
+
// Same rationale as connect(): hand the known account email to the
|
|
106
|
+
// popup_closed recovery probe so it can pick up a completed grant silently.
|
|
107
|
+
const existing = await getConn(opts.appId, opts.projectId);
|
|
98
108
|
const token = await acquireToken({
|
|
99
109
|
appId: opts.appId,
|
|
100
110
|
projectId: opts.projectId,
|
|
101
111
|
clientId: opts.clientId,
|
|
102
112
|
scopes: opts.scopes,
|
|
103
113
|
interactive: opts.interactive,
|
|
114
|
+
hint: existing?.email,
|
|
104
115
|
logger: opts.logger,
|
|
105
116
|
});
|
|
106
117
|
return token.accessToken;
|
package/dist/errors.d.ts
CHANGED
|
@@ -46,6 +46,15 @@ export declare class NotFoundError extends DriveSyncError {
|
|
|
46
46
|
fileId: string;
|
|
47
47
|
constructor(fileId: string, opts?: DriveSyncErrorOptions);
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Thrown when Drive refuses to serve a file's raw bytes via `alt=media`
|
|
51
|
+
* because it isn't binary content (e.g. a native Google Docs/Sheets/Slides
|
|
52
|
+
* file, which only supports Export, not Download).
|
|
53
|
+
*/
|
|
54
|
+
export declare class NotDownloadableError extends DriveSyncError {
|
|
55
|
+
fileId: string;
|
|
56
|
+
constructor(fileId: string, opts?: DriveSyncErrorOptions);
|
|
57
|
+
}
|
|
49
58
|
export interface RateLimitedErrorOptions extends DriveSyncErrorOptions {
|
|
50
59
|
retryAfter?: number;
|
|
51
60
|
}
|
package/dist/errors.js
CHANGED
|
@@ -59,6 +59,19 @@ export class NotFoundError extends DriveSyncError {
|
|
|
59
59
|
this.fileId = fileId;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Thrown when Drive refuses to serve a file's raw bytes via `alt=media`
|
|
64
|
+
* because it isn't binary content (e.g. a native Google Docs/Sheets/Slides
|
|
65
|
+
* file, which only supports Export, not Download).
|
|
66
|
+
*/
|
|
67
|
+
export class NotDownloadableError extends DriveSyncError {
|
|
68
|
+
fileId;
|
|
69
|
+
constructor(fileId, opts) {
|
|
70
|
+
super(`File not downloadable as raw content: ${fileId}`, { status: 403, ...opts });
|
|
71
|
+
this.name = 'NotDownloadableError';
|
|
72
|
+
this.fileId = fileId;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
62
75
|
/** Thrown on a 429 response. */
|
|
63
76
|
export class RateLimitedError extends DriveSyncError {
|
|
64
77
|
retryAfter;
|
package/dist/files.d.ts
CHANGED
|
@@ -18,7 +18,9 @@ export interface ReadOptions extends BaseCallOptions {
|
|
|
18
18
|
* since Drive 404s both for a genuinely wrong id and for a file the
|
|
19
19
|
* connected account cannot see — this ambiguity is documented in the public
|
|
20
20
|
* API and callers are expected to treat `null` as "not available" rather
|
|
21
|
-
* than distinguishing the two cases.
|
|
21
|
+
* than distinguishing the two cases. Also returns `null` for a native Google
|
|
22
|
+
* Workspace file (Docs/Sheets/Slides/...), since those only support Export,
|
|
23
|
+
* not the raw `alt=media` download this function issues.
|
|
22
24
|
*/
|
|
23
25
|
export declare function read(opts: ReadOptions): Promise<string | Blob | null>;
|
|
24
26
|
export interface RemoveOptions extends BaseCallOptions {
|
package/dist/files.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { driveFetch } from './http.js';
|
|
2
2
|
import { escapeQ } from './query.js';
|
|
3
|
-
import { NotFoundError, RemoteChangedError } from './errors.js';
|
|
3
|
+
import { NotFoundError, NotDownloadableError, RemoteChangedError } from './errors.js';
|
|
4
4
|
import { getFileState, setFileState, clearFileState } from './storage.js';
|
|
5
5
|
const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
|
|
6
6
|
const UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3';
|
|
@@ -53,7 +53,9 @@ async function recordBaseline(opts, fileId, version) {
|
|
|
53
53
|
* since Drive 404s both for a genuinely wrong id and for a file the
|
|
54
54
|
* connected account cannot see — this ambiguity is documented in the public
|
|
55
55
|
* API and callers are expected to treat `null` as "not available" rather
|
|
56
|
-
* than distinguishing the two cases.
|
|
56
|
+
* than distinguishing the two cases. Also returns `null` for a native Google
|
|
57
|
+
* Workspace file (Docs/Sheets/Slides/...), since those only support Export,
|
|
58
|
+
* not the raw `alt=media` download this function issues.
|
|
57
59
|
*/
|
|
58
60
|
export async function read(opts) {
|
|
59
61
|
try {
|
|
@@ -88,7 +90,7 @@ export async function read(opts) {
|
|
|
88
90
|
return content;
|
|
89
91
|
}
|
|
90
92
|
catch (err) {
|
|
91
|
-
if (err instanceof NotFoundError) {
|
|
93
|
+
if (err instanceof NotFoundError || err instanceof NotDownloadableError) {
|
|
92
94
|
return null;
|
|
93
95
|
}
|
|
94
96
|
throw err;
|
package/dist/http.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { acquireToken } from './token.js';
|
|
2
2
|
import { getConnection, refreshSilently } from './connection.js';
|
|
3
3
|
import { clearToken, getToken } from './storage.js';
|
|
4
|
-
import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
|
|
4
|
+
import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, NotDownloadableError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
|
|
5
5
|
const MAX_ATTEMPTS = 3;
|
|
6
6
|
const BASE_DELAY_MS = 500;
|
|
7
7
|
/** Mirrors connection.ts's own buffer: a cached token this close to expiry is treated as unusable. */
|
|
@@ -182,6 +182,12 @@ async function performFetch(opts, accessToken, isRetryAfter401) {
|
|
|
182
182
|
reason: lastBodyText,
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
|
+
if (lastBodyText.includes('fileNotDownloadable')) {
|
|
186
|
+
// No fileId is reliably available at this layer (see the 404 case
|
|
187
|
+
// below) — files.ts re-catches this and returns null instead of
|
|
188
|
+
// rethrowing with the concrete fileId, so the empty default is fine.
|
|
189
|
+
throw new NotDownloadableError('', { status: 403, reason: lastBodyText });
|
|
190
|
+
}
|
|
185
191
|
throw new DriveSyncError(`Drive request forbidden (403): ${lastBodyText}`, {
|
|
186
192
|
status: 403,
|
|
187
193
|
reason: lastBodyText,
|
package/dist/index.js
CHANGED
|
@@ -227,7 +227,11 @@ export function createDriveSync(options) {
|
|
|
227
227
|
});
|
|
228
228
|
const results = [];
|
|
229
229
|
for (const p of picked) {
|
|
230
|
-
|
|
230
|
+
// Not interactive: the token above was already acquired
|
|
231
|
+
// interactively moments ago for the Picker itself and is now
|
|
232
|
+
// cached, so each file read reuses it (or silently refreshes)
|
|
233
|
+
// instead of forcing its own consent prompt per file.
|
|
234
|
+
const content = await filesImpl.read({ ...base, fileId: p.fileId, interactive: false });
|
|
231
235
|
results.push({ fileId: p.fileId, name: p.name, mimeType: p.mimeType, content });
|
|
232
236
|
}
|
|
233
237
|
return results;
|
package/dist/token.js
CHANGED
|
@@ -14,6 +14,17 @@ import { createBroadcast } from './broadcast.js';
|
|
|
14
14
|
* long enough. `probeForCompletedGrant` below is what actually recovers those.
|
|
15
15
|
*/
|
|
16
16
|
const POPUP_CLOSED_GRACE_MS = 2000;
|
|
17
|
+
/**
|
|
18
|
+
* The silent `prompt: 'none'` probe that recovers a completed sign-in GIS
|
|
19
|
+
* misreported as `popup_closed` is retried a few times: GIS's popup-closed
|
|
20
|
+
* poll can fire before the just-granted consent is durably registered at
|
|
21
|
+
* Google, so a single immediate probe races the grant into existence and
|
|
22
|
+
* loses. A few spaced retries let a real grant surface while still failing
|
|
23
|
+
* fast enough that a genuine cancellation is reported promptly.
|
|
24
|
+
*/
|
|
25
|
+
const PROBE_ATTEMPTS = 3;
|
|
26
|
+
const PROBE_RETRY_DELAY_MS = 350;
|
|
27
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
28
|
/**
|
|
18
29
|
* Persists a freshly-acquired GIS token response as a StoredToken, deriving
|
|
19
30
|
* expiresAt from the response's own expires_in (never hardcoded) and
|
|
@@ -107,32 +118,46 @@ function isPopupClosedError(err) {
|
|
|
107
118
|
return err instanceof NeedsReauthError && err.reason === 'popup_closed';
|
|
108
119
|
}
|
|
109
120
|
/**
|
|
110
|
-
* Issues
|
|
111
|
-
* GIS reported as `popup_closed` actually completed.
|
|
112
|
-
* response
|
|
113
|
-
* — the original interactive failure —
|
|
114
|
-
* would have seen before, never a
|
|
121
|
+
* Issues up to `PROBE_ATTEMPTS` silent `prompt: 'none'` requests to find out
|
|
122
|
+
* whether the sign-in that GIS reported as `popup_closed` actually completed.
|
|
123
|
+
* Resolves with the token response as soon as a live grant is found;
|
|
124
|
+
* otherwise rethrows `popupClosedError` — the original interactive failure —
|
|
125
|
+
* so callers see the cancellation they would have seen before, never a
|
|
126
|
+
* confusing silent-path error.
|
|
127
|
+
*
|
|
128
|
+
* `opts.hint` matters here: a bare `prompt: 'none'` request with no
|
|
129
|
+
* `login_hint` cannot be resolved by GIS when the browser holds more than one
|
|
130
|
+
* Google session, so the interactive callers pass the connection's known
|
|
131
|
+
* email through as the hint for this probe.
|
|
115
132
|
*/
|
|
116
133
|
async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
|
|
117
134
|
opts.logger?.debug('drive-sync: popup_closed with no token; probing for a completed grant', {
|
|
118
135
|
projectId: opts.projectId,
|
|
119
136
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
let lastProbeError;
|
|
138
|
+
for (let attempt = 1; attempt <= PROBE_ATTEMPTS; attempt += 1) {
|
|
139
|
+
try {
|
|
140
|
+
// No grace window: `prompt: 'none'` never opens a popup, so there is no
|
|
141
|
+
// popup-closed poll to race and nothing to wait out on failure.
|
|
142
|
+
const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0);
|
|
143
|
+
opts.logger?.debug('drive-sync: recovered a completed sign-in reported as popup_closed', {
|
|
144
|
+
projectId: opts.projectId,
|
|
145
|
+
attempt,
|
|
146
|
+
});
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
catch (probeError) {
|
|
150
|
+
lastProbeError = probeError;
|
|
151
|
+
if (attempt < PROBE_ATTEMPTS) {
|
|
152
|
+
await delay(PROBE_RETRY_DELAY_MS);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
135
155
|
}
|
|
156
|
+
opts.logger?.debug('drive-sync: no live grant after popup_closed; treating as cancelled', {
|
|
157
|
+
projectId: opts.projectId,
|
|
158
|
+
probeError: lastProbeError,
|
|
159
|
+
});
|
|
160
|
+
throw popupClosedError;
|
|
136
161
|
}
|
|
137
162
|
/**
|
|
138
163
|
* Wraps a single GIS token request in a promise.
|