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/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # google-photos-picker-client
2
+
3
+ Framework-agnostic browser client for the
4
+ [`google-photos-picker`](https://github.com/samrford/google-photos-picker) Go
5
+ library's import flow: connection status, **popup-blocker-safe OAuth**, picker
6
+ session + import polling collapsed into one state machine with an
7
+ **exactly-once** completion, and `expired`-session handling. Optional React and
8
+ Svelte adapters.
9
+
10
+ ```sh
11
+ bun add google-photos-picker-client # or npm / pnpm
12
+ ```
13
+
14
+ ## The one rule: call `connect()` / `start()` from a click
15
+
16
+ Browsers only allow `window.open` during a user gesture. Each method opens
17
+ **one** popup synchronously before any `await`, so they must be invoked
18
+ directly from an event handler. The flow is **two gestures** when the user
19
+ isn't connected yet — gate on `state.connected`:
20
+
21
+ - falsy → `connect()` (OAuth popup)
22
+ - `true` → `start()` (picker → import)
23
+
24
+ ## Core (vanilla)
25
+
26
+ ```ts
27
+ import { GooglePhotosFlow, defaultEndpoints } from 'google-photos-picker-client';
28
+
29
+ const flow = new GooglePhotosFlow({
30
+ endpoints: defaultEndpoints('/v1/google-photos'), // or hand-build Endpoints
31
+ postMessageType: 'myapp:google-oauth', // === Go CallbackPage.PostMessageType
32
+ fetchJson: (url, init) => api(url, init), // you inject auth + base URL; throw on non-2xx
33
+ });
34
+
35
+ flow.subscribe((s) => render(s)); // { phase, connected, progress, result, error, expired }
36
+ await flow.refreshStatus(); // safe outside a gesture
37
+
38
+ button.onclick = () =>
39
+ flow.state.connected ? flow.start({ /* metadata? */ }) : flow.connect();
40
+ ```
41
+
42
+ `start()` resolves exactly once with `{ savedIds, total, completed, failed }`
43
+ (also placed on `state.result`). `metadata` is only for the lib's
44
+ client-supplied path; apps deriving the destination server-side omit it.
45
+
46
+ ## React
47
+
48
+ ```tsx
49
+ import { useGooglePhotosFlow } from 'google-photos-picker-client/react';
50
+
51
+ const cfg = useMemo(() => ({ endpoints: …, postMessageType: …, fetchJson: … }), []);
52
+ const { state, connect, start } = useGooglePhotosFlow(cfg);
53
+ ```
54
+
55
+ Status is fetched on mount, the flow cancelled on unmount. Memoise `cfg` — it's
56
+ read once.
57
+
58
+ ## Svelte
59
+
60
+ ```svelte
61
+ <script lang="ts">
62
+ import { createGooglePhotosFlow } from 'google-photos-picker-client/svelte';
63
+ const flow = createGooglePhotosFlow({ endpoints: …, postMessageType: …, fetchJson: … });
64
+ onMount(flow.refreshStatus); onDestroy(flow.cancel);
65
+ </script>
66
+
67
+ {#if $flow.phase === 'importing'}{$flow.progress?.completed}/{$flow.progress?.total}{/if}
68
+ ```
69
+
70
+ `flow` is a readable store (`$flow` = state) plus `connect` / `start` /
71
+ `disconnect` / `refreshStatus` / `cancel`. No `svelte` dependency.
72
+
73
+ ## Security: pin the OAuth origin in production
74
+
75
+ The OAuth result is delivered to the opener via `window.postMessage`. In
76
+ production **set `expectedOrigin`** to the origin that served the callback page
77
+ (your API origin) so a message forged by another window can't spoof a
78
+ successful connect — and set the Go side's `CallbackPage.TargetOrigin` to your
79
+ frontend origin instead of the `"*"` default. Leaving both at their permissive
80
+ defaults is acceptable for local dev only.
81
+
82
+ ## Config notes
83
+
84
+ - **`fetchJson`** is the single HTTP seam — inject the `Authorization` header
85
+ and base URL here, parse JSON, **throw on non-2xx**.
86
+ - **`postMessageType`** must equal the Go side's
87
+ `CallbackPage.PostMessageType`.
88
+ - **`expectedOrigin`** — the API origin that served the callback page. Optional
89
+ but **strongly recommended in production** (see Security above); unset = no
90
+ origin check, relying solely on the Go side's `targetOrigin`.
91
+ - **`endpoints`** are explicit because paths are the consumer's choice;
92
+ `defaultEndpoints(base)` covers the all-under-one-prefix case.
93
+
94
+ ## License
95
+
96
+ MIT © Sam Ford
@@ -0,0 +1,316 @@
1
+ // src/flow.ts
2
+ var FlowCancelled = class extends Error {
3
+ constructor() {
4
+ super("google-photos flow cancelled");
5
+ this.name = "FlowCancelled";
6
+ }
7
+ };
8
+ var DEFAULT_SESSION_POLL_MS = 2e3;
9
+ var DEFAULT_JOB_POLL_MS = 1500;
10
+ var MAX_POLL_FAILURES = 4;
11
+ var MAX_POLL_BACKOFF_MS = 15e3;
12
+ var OAUTH_TIMEOUT_MS = 5 * 60 * 1e3;
13
+ var INITIAL = {
14
+ phase: "idle",
15
+ connected: null,
16
+ progress: null,
17
+ result: null,
18
+ error: null,
19
+ expired: false
20
+ };
21
+ var GooglePhotosFlow = class {
22
+ constructor(config) {
23
+ this._state = INITIAL;
24
+ this.listeners = /* @__PURE__ */ new Set();
25
+ this.waiters = /* @__PURE__ */ new Set();
26
+ this.popup = null;
27
+ /** Bumped by cancel() and by each new run; stale loops detect this and bail. */
28
+ this.runId = 0;
29
+ this.config = config;
30
+ }
31
+ get state() {
32
+ return this._state;
33
+ }
34
+ /** Register a state listener. Does not emit the current value (framework
35
+ * adapters surface the initial value themselves). Returns an unsubscribe. */
36
+ subscribe(fn) {
37
+ this.listeners.add(fn);
38
+ return () => this.listeners.delete(fn);
39
+ }
40
+ setState(patch) {
41
+ this._state = { ...this._state, ...patch };
42
+ for (const fn of this.listeners) fn(this._state);
43
+ }
44
+ /** Abort any in-flight run, close popups, reset to idle (unless terminal). */
45
+ cancel() {
46
+ this.runId++;
47
+ this.clearTimers();
48
+ this.closePopup();
49
+ if (this._state.phase !== "done" && this._state.phase !== "error") {
50
+ this.setState({ phase: "idle", progress: null });
51
+ }
52
+ }
53
+ clearTimers() {
54
+ for (const w of this.waiters) {
55
+ clearTimeout(w.timer);
56
+ w.reject(new FlowCancelled());
57
+ }
58
+ this.waiters.clear();
59
+ }
60
+ closePopup() {
61
+ const p = this.popup;
62
+ this.popup = null;
63
+ if (p && !p.closed) {
64
+ try {
65
+ p.close();
66
+ } catch {
67
+ }
68
+ }
69
+ }
70
+ /** Fetch connection status. Safe to call on mount (not a user gesture). */
71
+ async refreshStatus() {
72
+ try {
73
+ const s = await this.config.fetchJson(this.config.endpoints.status);
74
+ this.setState({ connected: !!s.connected, error: null });
75
+ } catch {
76
+ this.setState({ connected: false });
77
+ }
78
+ }
79
+ /** Revoke the Google connection. */
80
+ async disconnect() {
81
+ try {
82
+ await this.config.fetchJson(this.config.endpoints.disconnect, { method: "DELETE" });
83
+ this.setState({ connected: false, error: null });
84
+ } catch (e) {
85
+ this.setState({ error: messageOf(e) });
86
+ throw e;
87
+ }
88
+ }
89
+ /**
90
+ * Run the OAuth dance. MUST be called from a user-gesture handler: a blank
91
+ * popup is opened synchronously, then pointed at the consent URL once the
92
+ * backend returns it, then resolved/rejected from the callback postMessage.
93
+ */
94
+ async connect() {
95
+ const win = this.openBlank("gpp-oauth");
96
+ const myRun = ++this.runId;
97
+ this.setState({ phase: "connecting", error: null, expired: false });
98
+ try {
99
+ if (!win) throw new Error("Popup blocked \u2014 allow popups and retry.");
100
+ this.popup = win;
101
+ const { consentUrl } = await this.config.fetchJson(
102
+ this.config.endpoints.connect
103
+ );
104
+ this.ensureCurrent(myRun);
105
+ if (win.closed) throw new Error("Authorisation window was closed.");
106
+ win.location.href = consentUrl;
107
+ await this.waitForOAuth(myRun);
108
+ this.closePopup();
109
+ this.setState({ phase: "idle", connected: true });
110
+ } catch (e) {
111
+ this.closePopup();
112
+ this.handleError(e, myRun);
113
+ throw e;
114
+ }
115
+ }
116
+ /**
117
+ * Run create-session → pick → import to completion. MUST be called from a
118
+ * user-gesture handler and only when `state.connected` is true (the UI
119
+ * gates this; if called disconnected it errors fast). Resolves exactly once
120
+ * with the final result; the same result is also placed on `state.result`.
121
+ */
122
+ async start(opts = {}) {
123
+ const myRun = ++this.runId;
124
+ try {
125
+ if (this._state.connected === false) {
126
+ throw new Error("Not connected to Google Photos \u2014 connect first.");
127
+ }
128
+ const win = this.openBlank("gpp-picker");
129
+ this.setState({
130
+ phase: "creating",
131
+ error: null,
132
+ expired: false,
133
+ result: null,
134
+ progress: null
135
+ });
136
+ if (!win) throw new Error("Popup blocked \u2014 allow popups and retry.");
137
+ this.popup = win;
138
+ const session = await this.config.fetchJson(
139
+ this.config.endpoints.createSession,
140
+ { method: "POST" }
141
+ );
142
+ this.ensureCurrent(myRun);
143
+ if (win.closed) throw new Error("Picker window was closed.");
144
+ win.location.href = session.pickerUri;
145
+ this.setState({ phase: "picking" });
146
+ await this.pollSession(session.sessionId, myRun);
147
+ this.closePopup();
148
+ this.setState({ phase: "importing" });
149
+ const { importJobId } = await this.config.fetchJson(
150
+ this.config.endpoints.startImport(session.sessionId),
151
+ opts.metadata ? {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify({ metadata: opts.metadata })
155
+ } : { method: "POST" }
156
+ );
157
+ this.ensureCurrent(myRun);
158
+ const result = await this.pollJob(importJobId, myRun);
159
+ this.setState({ phase: "done", result });
160
+ return result;
161
+ } catch (e) {
162
+ this.closePopup();
163
+ this.handleError(e, myRun);
164
+ throw e;
165
+ }
166
+ }
167
+ // ─── internals ────────────────────────────────────────────────────────────
168
+ openBlank(name) {
169
+ const open = this.config.openWindow ?? ((u, n) => window.open(u, n));
170
+ return open("about:blank", name);
171
+ }
172
+ ensureCurrent(myRun) {
173
+ if (this.runId !== myRun) throw new FlowCancelled();
174
+ }
175
+ // Resolves after `ms`, unless the run is superseded/cancelled first - then it
176
+ // rejects with FlowCancelled. Registered in `waiters` so clearTimers() can
177
+ // interrupt an in-flight wait, which makes it abortable on demand.
178
+ cancellableDelay(ms, myRun) {
179
+ return new Promise((resolve, reject) => {
180
+ const w = {
181
+ timer: setTimeout(() => {
182
+ this.waiters.delete(w);
183
+ this.runId === myRun ? resolve() : reject(new FlowCancelled());
184
+ }, ms),
185
+ reject
186
+ };
187
+ this.waiters.add(w);
188
+ });
189
+ }
190
+ // Polling fetch with retry + exponential backoff.
191
+ // FlowCancelled and cancellation always propagate at once.
192
+ async pollFetch(url, myRun, baseMs) {
193
+ let failures = 0;
194
+ for (; ; ) {
195
+ try {
196
+ return await this.config.fetchJson(url);
197
+ } catch (e) {
198
+ if (e instanceof FlowCancelled) throw e;
199
+ this.ensureCurrent(myRun);
200
+ if (++failures > MAX_POLL_FAILURES) throw e;
201
+ await this.cancellableDelay(Math.min(baseMs * 2 ** (failures - 1), MAX_POLL_BACKOFF_MS), myRun);
202
+ }
203
+ }
204
+ }
205
+ async pollSession(sessionId, myRun) {
206
+ const interval = this.config.pollIntervalMs?.session ?? DEFAULT_SESSION_POLL_MS;
207
+ for (; ; ) {
208
+ const s = await this.pollFetch(
209
+ this.config.endpoints.pollSession(sessionId),
210
+ myRun,
211
+ interval
212
+ );
213
+ this.ensureCurrent(myRun);
214
+ if (s.status === "ready") return;
215
+ if (s.status === "expired") {
216
+ const err = new Error("The Google Photos session expired before you confirmed a selection.");
217
+ err.expired = true;
218
+ throw err;
219
+ }
220
+ await this.cancellableDelay(interval, myRun);
221
+ }
222
+ }
223
+ async pollJob(jobId, myRun) {
224
+ const interval = this.config.pollIntervalMs?.job ?? DEFAULT_JOB_POLL_MS;
225
+ for (; ; ) {
226
+ const job = await this.pollFetch(
227
+ this.config.endpoints.getImport(jobId),
228
+ myRun,
229
+ interval
230
+ );
231
+ this.ensureCurrent(myRun);
232
+ this.setState({
233
+ progress: { total: job.total, completed: job.completed, failed: job.failed }
234
+ });
235
+ if (job.status === "complete") {
236
+ return {
237
+ savedIds: job.savedIds ?? [],
238
+ total: job.total,
239
+ completed: job.completed,
240
+ failed: job.failed
241
+ };
242
+ }
243
+ if (job.status === "failed") {
244
+ throw new Error(job.error || "Import failed.");
245
+ }
246
+ await this.cancellableDelay(interval, myRun);
247
+ }
248
+ }
249
+ // Resolves once the Go callback page (loaded in the popup after Google's
250
+ // redirect) posts `{ type, status, message }` back via window.opener.
251
+ // Settles exactly once — the matching postMessage (success → resolve, error
252
+ // → reject with its message), the run being superseded (→ FlowCancelled), or
253
+ // an overall timeout. The timeout is the backstop for if the
254
+ // user abandons consent. Non-matching messages (wrong type, or wrong origin
255
+ // when `expectedOrigin` is set) are ignored, not rejected — other
256
+ // postMessages on the page must pass through untouched.
257
+ waitForOAuth(myRun) {
258
+ const target = this.config.messageTarget ?? globalThis;
259
+ const expectedOrigin = this.config.expectedOrigin;
260
+ const type = this.config.postMessageType;
261
+ return new Promise((resolve, reject) => {
262
+ let settled = false;
263
+ const finish = () => {
264
+ if (settled) return;
265
+ settled = true;
266
+ clearInterval(poll);
267
+ clearTimeout(timeout);
268
+ target.removeEventListener("message", onMessage);
269
+ };
270
+ const onMessage = (e) => {
271
+ if (this.runId !== myRun) {
272
+ finish();
273
+ reject(new FlowCancelled());
274
+ return;
275
+ }
276
+ if (expectedOrigin !== void 0 && e.origin !== expectedOrigin) return;
277
+ const d = e.data;
278
+ if (!d || typeof d !== "object" || d.type !== type) return;
279
+ finish();
280
+ if (d.status === "success") resolve();
281
+ else reject(new Error(typeof d.message === "string" && d.message ? d.message : "Google authorisation failed."));
282
+ };
283
+ const poll = setInterval(() => {
284
+ if (this.runId !== myRun) {
285
+ finish();
286
+ reject(new FlowCancelled());
287
+ }
288
+ }, 500);
289
+ const timeout = setTimeout(() => {
290
+ finish();
291
+ reject(new Error("Google authorisation timed out \u2014 please try again."));
292
+ }, OAUTH_TIMEOUT_MS);
293
+ target.addEventListener("message", onMessage);
294
+ });
295
+ }
296
+ handleError(e, myRun) {
297
+ if (e instanceof FlowCancelled || this.runId !== myRun) {
298
+ return;
299
+ }
300
+ this.setState({
301
+ phase: "error",
302
+ error: messageOf(e),
303
+ expired: isExpired(e)
304
+ });
305
+ }
306
+ };
307
+ function isExpired(e) {
308
+ return !!(e && typeof e === "object" && e.expired === true);
309
+ }
310
+ function messageOf(e) {
311
+ if (e instanceof Error) return e.message;
312
+ if (typeof e === "string") return e;
313
+ return "Something went wrong.";
314
+ }
315
+
316
+ export { FlowCancelled, GooglePhotosFlow };