google-photos-picker-client 0.0.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/dist/index.cjs ADDED
@@ -0,0 +1,335 @@
1
+ 'use strict';
2
+
3
+ // src/flow.ts
4
+ var FlowCancelled = class extends Error {
5
+ constructor() {
6
+ super("google-photos flow cancelled");
7
+ this.name = "FlowCancelled";
8
+ }
9
+ };
10
+ var DEFAULT_SESSION_POLL_MS = 2e3;
11
+ var DEFAULT_JOB_POLL_MS = 1500;
12
+ var MAX_POLL_FAILURES = 4;
13
+ var MAX_POLL_BACKOFF_MS = 15e3;
14
+ var OAUTH_TIMEOUT_MS = 5 * 60 * 1e3;
15
+ var INITIAL = {
16
+ phase: "idle",
17
+ connected: null,
18
+ progress: null,
19
+ result: null,
20
+ error: null,
21
+ expired: false
22
+ };
23
+ var GooglePhotosFlow = class {
24
+ constructor(config) {
25
+ this._state = INITIAL;
26
+ this.listeners = /* @__PURE__ */ new Set();
27
+ this.waiters = /* @__PURE__ */ new Set();
28
+ this.popup = null;
29
+ /** Bumped by cancel() and by each new run; stale loops detect this and bail. */
30
+ this.runId = 0;
31
+ this.config = config;
32
+ }
33
+ get state() {
34
+ return this._state;
35
+ }
36
+ /** Register a state listener. Does not emit the current value (framework
37
+ * adapters surface the initial value themselves). Returns an unsubscribe. */
38
+ subscribe(fn) {
39
+ this.listeners.add(fn);
40
+ return () => this.listeners.delete(fn);
41
+ }
42
+ setState(patch) {
43
+ this._state = { ...this._state, ...patch };
44
+ for (const fn of this.listeners) fn(this._state);
45
+ }
46
+ /** Abort any in-flight run, close popups, reset to idle (unless terminal). */
47
+ cancel() {
48
+ this.runId++;
49
+ this.clearTimers();
50
+ this.closePopup();
51
+ if (this._state.phase !== "done" && this._state.phase !== "error") {
52
+ this.setState({ phase: "idle", progress: null });
53
+ }
54
+ }
55
+ clearTimers() {
56
+ for (const w of this.waiters) {
57
+ clearTimeout(w.timer);
58
+ w.reject(new FlowCancelled());
59
+ }
60
+ this.waiters.clear();
61
+ }
62
+ closePopup() {
63
+ const p = this.popup;
64
+ this.popup = null;
65
+ if (p && !p.closed) {
66
+ try {
67
+ p.close();
68
+ } catch {
69
+ }
70
+ }
71
+ }
72
+ /** Fetch connection status. Safe to call on mount (not a user gesture). */
73
+ async refreshStatus() {
74
+ try {
75
+ const s = await this.config.fetchJson(this.config.endpoints.status);
76
+ this.setState({ connected: !!s.connected, error: null });
77
+ } catch {
78
+ this.setState({ connected: false });
79
+ }
80
+ }
81
+ /** Revoke the Google connection. */
82
+ async disconnect() {
83
+ try {
84
+ await this.config.fetchJson(this.config.endpoints.disconnect, { method: "DELETE" });
85
+ this.setState({ connected: false, error: null });
86
+ } catch (e) {
87
+ this.setState({ error: messageOf(e) });
88
+ throw e;
89
+ }
90
+ }
91
+ /**
92
+ * Run the OAuth dance. MUST be called from a user-gesture handler: a blank
93
+ * popup is opened synchronously, then pointed at the consent URL once the
94
+ * backend returns it, then resolved/rejected from the callback postMessage.
95
+ */
96
+ async connect() {
97
+ const win = this.openBlank("gpp-oauth");
98
+ const myRun = ++this.runId;
99
+ this.setState({ phase: "connecting", error: null, expired: false });
100
+ try {
101
+ if (!win) throw new Error("Popup blocked \u2014 allow popups and retry.");
102
+ this.popup = win;
103
+ const { consentUrl } = await this.config.fetchJson(
104
+ this.config.endpoints.connect
105
+ );
106
+ this.ensureCurrent(myRun);
107
+ if (win.closed) throw new Error("Authorisation window was closed.");
108
+ win.location.href = consentUrl;
109
+ await this.waitForOAuth(myRun);
110
+ this.closePopup();
111
+ this.setState({ phase: "idle", connected: true });
112
+ } catch (e) {
113
+ this.closePopup();
114
+ this.handleError(e, myRun);
115
+ throw e;
116
+ }
117
+ }
118
+ /**
119
+ * Run create-session → pick → import to completion. MUST be called from a
120
+ * user-gesture handler and only when `state.connected` is true (the UI
121
+ * gates this; if called disconnected it errors fast). Resolves exactly once
122
+ * with the final result; the same result is also placed on `state.result`.
123
+ */
124
+ async start(opts = {}) {
125
+ const myRun = ++this.runId;
126
+ try {
127
+ if (this._state.connected === false) {
128
+ throw new Error("Not connected to Google Photos \u2014 connect first.");
129
+ }
130
+ const win = this.openBlank("gpp-picker");
131
+ this.setState({
132
+ phase: "creating",
133
+ error: null,
134
+ expired: false,
135
+ result: null,
136
+ progress: null
137
+ });
138
+ if (!win) throw new Error("Popup blocked \u2014 allow popups and retry.");
139
+ this.popup = win;
140
+ const session = await this.config.fetchJson(
141
+ this.config.endpoints.createSession,
142
+ { method: "POST" }
143
+ );
144
+ this.ensureCurrent(myRun);
145
+ if (win.closed) throw new Error("Picker window was closed.");
146
+ win.location.href = session.pickerUri;
147
+ this.setState({ phase: "picking" });
148
+ await this.pollSession(session.sessionId, myRun);
149
+ this.closePopup();
150
+ this.setState({ phase: "importing" });
151
+ const { importJobId } = await this.config.fetchJson(
152
+ this.config.endpoints.startImport(session.sessionId),
153
+ opts.metadata ? {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ metadata: opts.metadata })
157
+ } : { method: "POST" }
158
+ );
159
+ this.ensureCurrent(myRun);
160
+ const result = await this.pollJob(importJobId, myRun);
161
+ this.setState({ phase: "done", result });
162
+ return result;
163
+ } catch (e) {
164
+ this.closePopup();
165
+ this.handleError(e, myRun);
166
+ throw e;
167
+ }
168
+ }
169
+ // ─── internals ────────────────────────────────────────────────────────────
170
+ openBlank(name) {
171
+ const open = this.config.openWindow ?? ((u, n) => window.open(u, n));
172
+ return open("about:blank", name);
173
+ }
174
+ ensureCurrent(myRun) {
175
+ if (this.runId !== myRun) throw new FlowCancelled();
176
+ }
177
+ // Resolves after `ms`, unless the run is superseded/cancelled first - then it
178
+ // rejects with FlowCancelled. Registered in `waiters` so clearTimers() can
179
+ // interrupt an in-flight wait, which makes it abortable on demand.
180
+ cancellableDelay(ms, myRun) {
181
+ return new Promise((resolve, reject) => {
182
+ const w = {
183
+ timer: setTimeout(() => {
184
+ this.waiters.delete(w);
185
+ this.runId === myRun ? resolve() : reject(new FlowCancelled());
186
+ }, ms),
187
+ reject
188
+ };
189
+ this.waiters.add(w);
190
+ });
191
+ }
192
+ // Polling fetch with retry + exponential backoff.
193
+ // FlowCancelled and cancellation always propagate at once.
194
+ async pollFetch(url, myRun, baseMs) {
195
+ let failures = 0;
196
+ for (; ; ) {
197
+ try {
198
+ return await this.config.fetchJson(url);
199
+ } catch (e) {
200
+ if (e instanceof FlowCancelled) throw e;
201
+ this.ensureCurrent(myRun);
202
+ if (++failures > MAX_POLL_FAILURES) throw e;
203
+ await this.cancellableDelay(Math.min(baseMs * 2 ** (failures - 1), MAX_POLL_BACKOFF_MS), myRun);
204
+ }
205
+ }
206
+ }
207
+ async pollSession(sessionId, myRun) {
208
+ const interval = this.config.pollIntervalMs?.session ?? DEFAULT_SESSION_POLL_MS;
209
+ for (; ; ) {
210
+ const s = await this.pollFetch(
211
+ this.config.endpoints.pollSession(sessionId),
212
+ myRun,
213
+ interval
214
+ );
215
+ this.ensureCurrent(myRun);
216
+ if (s.status === "ready") return;
217
+ if (s.status === "expired") {
218
+ const err = new Error("The Google Photos session expired before you confirmed a selection.");
219
+ err.expired = true;
220
+ throw err;
221
+ }
222
+ await this.cancellableDelay(interval, myRun);
223
+ }
224
+ }
225
+ async pollJob(jobId, myRun) {
226
+ const interval = this.config.pollIntervalMs?.job ?? DEFAULT_JOB_POLL_MS;
227
+ for (; ; ) {
228
+ const job = await this.pollFetch(
229
+ this.config.endpoints.getImport(jobId),
230
+ myRun,
231
+ interval
232
+ );
233
+ this.ensureCurrent(myRun);
234
+ this.setState({
235
+ progress: { total: job.total, completed: job.completed, failed: job.failed }
236
+ });
237
+ if (job.status === "complete") {
238
+ return {
239
+ savedIds: job.savedIds ?? [],
240
+ total: job.total,
241
+ completed: job.completed,
242
+ failed: job.failed
243
+ };
244
+ }
245
+ if (job.status === "failed") {
246
+ throw new Error(job.error || "Import failed.");
247
+ }
248
+ await this.cancellableDelay(interval, myRun);
249
+ }
250
+ }
251
+ // Resolves once the Go callback page (loaded in the popup after Google's
252
+ // redirect) posts `{ type, status, message }` back via window.opener.
253
+ // Settles exactly once — the matching postMessage (success → resolve, error
254
+ // → reject with its message), the run being superseded (→ FlowCancelled), or
255
+ // an overall timeout. The timeout is the backstop for if the
256
+ // user abandons consent. Non-matching messages (wrong type, or wrong origin
257
+ // when `expectedOrigin` is set) are ignored, not rejected — other
258
+ // postMessages on the page must pass through untouched.
259
+ waitForOAuth(myRun) {
260
+ const target = this.config.messageTarget ?? globalThis;
261
+ const expectedOrigin = this.config.expectedOrigin;
262
+ const type = this.config.postMessageType;
263
+ return new Promise((resolve, reject) => {
264
+ let settled = false;
265
+ const finish = () => {
266
+ if (settled) return;
267
+ settled = true;
268
+ clearInterval(poll);
269
+ clearTimeout(timeout);
270
+ target.removeEventListener("message", onMessage);
271
+ };
272
+ const onMessage = (e) => {
273
+ if (this.runId !== myRun) {
274
+ finish();
275
+ reject(new FlowCancelled());
276
+ return;
277
+ }
278
+ if (expectedOrigin !== void 0 && e.origin !== expectedOrigin) return;
279
+ const d = e.data;
280
+ if (!d || typeof d !== "object" || d.type !== type) return;
281
+ finish();
282
+ if (d.status === "success") resolve();
283
+ else reject(new Error(typeof d.message === "string" && d.message ? d.message : "Google authorisation failed."));
284
+ };
285
+ const poll = setInterval(() => {
286
+ if (this.runId !== myRun) {
287
+ finish();
288
+ reject(new FlowCancelled());
289
+ }
290
+ }, 500);
291
+ const timeout = setTimeout(() => {
292
+ finish();
293
+ reject(new Error("Google authorisation timed out \u2014 please try again."));
294
+ }, OAUTH_TIMEOUT_MS);
295
+ target.addEventListener("message", onMessage);
296
+ });
297
+ }
298
+ handleError(e, myRun) {
299
+ if (e instanceof FlowCancelled || this.runId !== myRun) {
300
+ return;
301
+ }
302
+ this.setState({
303
+ phase: "error",
304
+ error: messageOf(e),
305
+ expired: isExpired(e)
306
+ });
307
+ }
308
+ };
309
+ function isExpired(e) {
310
+ return !!(e && typeof e === "object" && e.expired === true);
311
+ }
312
+ function messageOf(e) {
313
+ if (e instanceof Error) return e.message;
314
+ if (typeof e === "string") return e;
315
+ return "Something went wrong.";
316
+ }
317
+
318
+ // src/endpoints.ts
319
+ function defaultEndpoints(base) {
320
+ const b = base.replace(/\/$/, "");
321
+ const enc = encodeURIComponent;
322
+ return {
323
+ status: `${b}/status`,
324
+ connect: `${b}/connect`,
325
+ disconnect: `${b}/disconnect`,
326
+ createSession: `${b}/sessions`,
327
+ pollSession: (id) => `${b}/sessions/${enc(id)}`,
328
+ startImport: (id) => `${b}/sessions/${enc(id)}/import`,
329
+ getImport: (id) => `${b}/imports/${enc(id)}`
330
+ };
331
+ }
332
+
333
+ exports.FlowCancelled = FlowCancelled;
334
+ exports.GooglePhotosFlow = GooglePhotosFlow;
335
+ exports.defaultEndpoints = defaultEndpoints;
@@ -0,0 +1,84 @@
1
+ import { F as FlowConfig, c as FlowState, g as StartOptions, C as CompleteResult, E as Endpoints } from './types-BUF5FPLM.cjs';
2
+ export { a as CreateSessionResponse, b as FlowPhase, G as GoogleStatus, I as ImportJob, d as ImportJobStatus, e as ImportProgress, S as SessionStatus, f as StartImportResponse } from './types-BUF5FPLM.cjs';
3
+
4
+ /** Thrown internally when a run is superseded or cancelled. Not surfaced as a
5
+ * flow error — the state simply returns to idle. */
6
+ declare class FlowCancelled extends Error {
7
+ constructor();
8
+ }
9
+ /**
10
+ * GooglePhotosFlow drives the entire import flow framework-agnostically:
11
+ * status, popup-safe OAuth, picker session, both poll loops, and an
12
+ * exactly-once completion. It owns its timers and emits state changes to
13
+ * subscribers; framework adapters are thin wrappers over `subscribe` + the
14
+ * action methods.
15
+ *
16
+ * Popup-blocker safety: `connect()` and `start()` each open exactly one window
17
+ * synchronously, before any `await` and before the first `setState()` (only a
18
+ * cheap synchronous precheck may precede it), so they must be called from a
19
+ * user-gesture handler. They never open a second window after an await (the
20
+ * fragile pattern). If the user isn't connected, the UI flow is two
21
+ * gestures: click → connect(), then click → start().
22
+ */
23
+ declare class GooglePhotosFlow {
24
+ private readonly config;
25
+ private _state;
26
+ private readonly listeners;
27
+ private readonly waiters;
28
+ private popup;
29
+ /** Bumped by cancel() and by each new run; stale loops detect this and bail. */
30
+ private runId;
31
+ constructor(config: FlowConfig);
32
+ get state(): FlowState;
33
+ /** Register a state listener. Does not emit the current value (framework
34
+ * adapters surface the initial value themselves). Returns an unsubscribe. */
35
+ subscribe(fn: (s: FlowState) => void): () => void;
36
+ private setState;
37
+ /** Abort any in-flight run, close popups, reset to idle (unless terminal). */
38
+ cancel(): void;
39
+ private clearTimers;
40
+ private closePopup;
41
+ /** Fetch connection status. Safe to call on mount (not a user gesture). */
42
+ refreshStatus(): Promise<void>;
43
+ /** Revoke the Google connection. */
44
+ disconnect(): Promise<void>;
45
+ /**
46
+ * Run the OAuth dance. MUST be called from a user-gesture handler: a blank
47
+ * popup is opened synchronously, then pointed at the consent URL once the
48
+ * backend returns it, then resolved/rejected from the callback postMessage.
49
+ */
50
+ connect(): Promise<void>;
51
+ /**
52
+ * Run create-session → pick → import to completion. MUST be called from a
53
+ * user-gesture handler and only when `state.connected` is true (the UI
54
+ * gates this; if called disconnected it errors fast). Resolves exactly once
55
+ * with the final result; the same result is also placed on `state.result`.
56
+ */
57
+ start(opts?: StartOptions): Promise<CompleteResult>;
58
+ private openBlank;
59
+ private ensureCurrent;
60
+ private cancellableDelay;
61
+ private pollFetch;
62
+ private pollSession;
63
+ private pollJob;
64
+ private waitForOAuth;
65
+ private handleError;
66
+ }
67
+
68
+ /**
69
+ * Conventional endpoint layout for the common case: all routes under a single
70
+ * base path. Consumers whose routes diverge (e.g. status/connect under a
71
+ * different prefix) build the `Endpoints` object by hand instead.
72
+ *
73
+ * defaultEndpoints('/v1/google-photos')
74
+ * → POST /v1/google-photos/sessions
75
+ * GET /v1/google-photos/sessions/:id
76
+ * POST /v1/google-photos/sessions/:id/import
77
+ * GET /v1/google-photos/imports/:id
78
+ * GET /v1/google-photos/status
79
+ * GET /v1/google-photos/connect
80
+ * DELETE /v1/google-photos/disconnect
81
+ */
82
+ declare function defaultEndpoints(base: string): Endpoints;
83
+
84
+ export { CompleteResult, Endpoints, FlowCancelled, FlowConfig, FlowState, GooglePhotosFlow, StartOptions, defaultEndpoints };
@@ -0,0 +1,84 @@
1
+ import { F as FlowConfig, c as FlowState, g as StartOptions, C as CompleteResult, E as Endpoints } from './types-BUF5FPLM.js';
2
+ export { a as CreateSessionResponse, b as FlowPhase, G as GoogleStatus, I as ImportJob, d as ImportJobStatus, e as ImportProgress, S as SessionStatus, f as StartImportResponse } from './types-BUF5FPLM.js';
3
+
4
+ /** Thrown internally when a run is superseded or cancelled. Not surfaced as a
5
+ * flow error — the state simply returns to idle. */
6
+ declare class FlowCancelled extends Error {
7
+ constructor();
8
+ }
9
+ /**
10
+ * GooglePhotosFlow drives the entire import flow framework-agnostically:
11
+ * status, popup-safe OAuth, picker session, both poll loops, and an
12
+ * exactly-once completion. It owns its timers and emits state changes to
13
+ * subscribers; framework adapters are thin wrappers over `subscribe` + the
14
+ * action methods.
15
+ *
16
+ * Popup-blocker safety: `connect()` and `start()` each open exactly one window
17
+ * synchronously, before any `await` and before the first `setState()` (only a
18
+ * cheap synchronous precheck may precede it), so they must be called from a
19
+ * user-gesture handler. They never open a second window after an await (the
20
+ * fragile pattern). If the user isn't connected, the UI flow is two
21
+ * gestures: click → connect(), then click → start().
22
+ */
23
+ declare class GooglePhotosFlow {
24
+ private readonly config;
25
+ private _state;
26
+ private readonly listeners;
27
+ private readonly waiters;
28
+ private popup;
29
+ /** Bumped by cancel() and by each new run; stale loops detect this and bail. */
30
+ private runId;
31
+ constructor(config: FlowConfig);
32
+ get state(): FlowState;
33
+ /** Register a state listener. Does not emit the current value (framework
34
+ * adapters surface the initial value themselves). Returns an unsubscribe. */
35
+ subscribe(fn: (s: FlowState) => void): () => void;
36
+ private setState;
37
+ /** Abort any in-flight run, close popups, reset to idle (unless terminal). */
38
+ cancel(): void;
39
+ private clearTimers;
40
+ private closePopup;
41
+ /** Fetch connection status. Safe to call on mount (not a user gesture). */
42
+ refreshStatus(): Promise<void>;
43
+ /** Revoke the Google connection. */
44
+ disconnect(): Promise<void>;
45
+ /**
46
+ * Run the OAuth dance. MUST be called from a user-gesture handler: a blank
47
+ * popup is opened synchronously, then pointed at the consent URL once the
48
+ * backend returns it, then resolved/rejected from the callback postMessage.
49
+ */
50
+ connect(): Promise<void>;
51
+ /**
52
+ * Run create-session → pick → import to completion. MUST be called from a
53
+ * user-gesture handler and only when `state.connected` is true (the UI
54
+ * gates this; if called disconnected it errors fast). Resolves exactly once
55
+ * with the final result; the same result is also placed on `state.result`.
56
+ */
57
+ start(opts?: StartOptions): Promise<CompleteResult>;
58
+ private openBlank;
59
+ private ensureCurrent;
60
+ private cancellableDelay;
61
+ private pollFetch;
62
+ private pollSession;
63
+ private pollJob;
64
+ private waitForOAuth;
65
+ private handleError;
66
+ }
67
+
68
+ /**
69
+ * Conventional endpoint layout for the common case: all routes under a single
70
+ * base path. Consumers whose routes diverge (e.g. status/connect under a
71
+ * different prefix) build the `Endpoints` object by hand instead.
72
+ *
73
+ * defaultEndpoints('/v1/google-photos')
74
+ * → POST /v1/google-photos/sessions
75
+ * GET /v1/google-photos/sessions/:id
76
+ * POST /v1/google-photos/sessions/:id/import
77
+ * GET /v1/google-photos/imports/:id
78
+ * GET /v1/google-photos/status
79
+ * GET /v1/google-photos/connect
80
+ * DELETE /v1/google-photos/disconnect
81
+ */
82
+ declare function defaultEndpoints(base: string): Endpoints;
83
+
84
+ export { CompleteResult, Endpoints, FlowCancelled, FlowConfig, FlowState, GooglePhotosFlow, StartOptions, defaultEndpoints };
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export { FlowCancelled, GooglePhotosFlow } from './chunk-ZO4U2RBX.js';
2
+
3
+ // src/endpoints.ts
4
+ function defaultEndpoints(base) {
5
+ const b = base.replace(/\/$/, "");
6
+ const enc = encodeURIComponent;
7
+ return {
8
+ status: `${b}/status`,
9
+ connect: `${b}/connect`,
10
+ disconnect: `${b}/disconnect`,
11
+ createSession: `${b}/sessions`,
12
+ pollSession: (id) => `${b}/sessions/${enc(id)}`,
13
+ startImport: (id) => `${b}/sessions/${enc(id)}/import`,
14
+ getImport: (id) => `${b}/imports/${enc(id)}`
15
+ };
16
+ }
17
+
18
+ export { defaultEndpoints };