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 +96 -0
- package/dist/chunk-ZO4U2RBX.js +316 -0
- package/dist/index.cjs +335 -0
- package/dist/index.d.cts +84 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +18 -0
- package/dist/react.cjs +349 -0
- package/dist/react.d.cts +23 -0
- package/dist/react.d.ts +23 -0
- package/dist/react.js +30 -0
- package/dist/svelte.cjs +334 -0
- package/dist/svelte.d.cts +23 -0
- package/dist/svelte.d.ts +23 -0
- package/dist/svelte.js +19 -0
- package/dist/types-BUF5FPLM.d.cts +104 -0
- package/dist/types-BUF5FPLM.d.ts +104 -0
- package/package.json +69 -0
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 };
|