@soorya-u/better-auth-desktop 0.1.0 → 0.1.2

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 CHANGED
@@ -113,6 +113,7 @@ export const authClient = createAuthClient({
113
113
  clientID: "my-desktop-app",
114
114
  storage: await keychainStorage(),
115
115
  // loopbackPort: 51789, // optional; omit to bind 127.0.0.1:0 (OS-assigned)
116
+ // onStorageError: (err) => console.error("Keychain write failed", err),
116
117
  }),
117
118
  ],
118
119
  });
@@ -142,8 +143,12 @@ auth.onAuthenticated((user) => navigate("/app"));
142
143
  await auth.requestAuth({ provider: "github" });
143
144
  ```
144
145
 
145
- The bridge exposes `requestAuth`, `getUser`, `signOut`, `getUserImage`, and the
146
- `onAuthenticated` / `onUserUpdated` / `onAuthError` subscriptions.
146
+ The bridge exposes: `requestAuth` (open browser flow), `getAuthUrl` (return the
147
+ URL without opening a browser — useful for copy-link flows), `getUser`,
148
+ `getSession`, `signOut`, `getUserImage`, `watchUser` (reactive subscription to
149
+ the current user, race-safe), `destroy` (remove all listeners on unmount /
150
+ hot-reload), and the `onAuthenticated` / `onUserUpdated` / `onAuthError` event
151
+ subscriptions.
147
152
 
148
153
  No `urlSchemes` / `protocol` registration is needed in `electrobun.config.ts`.
149
154
 
@@ -199,6 +204,32 @@ const authClient = createAuthClient({ plugins: [webDesktop()] });
199
204
  authClient.forwardToDesktop();
200
205
  ```
201
206
 
207
+ ### Wrapping an auth client (renderer / web)
208
+
209
+ `wrapForDesktop` proxy-wraps a `better-auth` client so that `getSession`,
210
+ `signOut`, and `signIn.social` delegate to the desktop bridge instead of hitting
211
+ the network directly. Pass `null` as the bridge to get the original client
212
+ back unchanged (useful during SSR / before the bridge is initialized).
213
+
214
+ ```ts
215
+ import { wrapForDesktop } from "@soorya-u/better-auth-desktop/web";
216
+
217
+ // wrap once, use everywhere like a normal better-auth client
218
+ const authClient = wrapForDesktop(baseClient, bridge);
219
+ await authClient.signIn.social({ provider: "github" }); // → opens loopback flow
220
+ await authClient.signOut(); // → bridge.signOut()
221
+ ```
222
+
223
+ To also intercept `useSession`, pass a framework hook as the third argument:
224
+
225
+ ```ts
226
+ import { useSession } from "@soorya-u/better-auth-desktop/react"; // React
227
+ const authClient = wrapForDesktop(baseClient, bridge, useSession);
228
+ ```
229
+
230
+ Or supply your own hook for Vue / Svelte — type it with `UseSessionFn` from
231
+ `@soorya-u/better-auth-desktop/web`.
232
+
202
233
  ### Customizing the loopback success page
203
234
 
204
235
  By default the loopback serves a minimal "you can close this tab" page. Override
@@ -240,7 +271,8 @@ type DesktopAdapter = {
240
271
  | `@soorya-u/better-auth-desktop/electrobun` | `electrobunDesktop()` plugin + `keychainStorage()` (Bun main process) |
241
272
  | `@soorya-u/better-auth-desktop/rpc/webview` | `defineAuthWebviewRPC()` (Electrobun renderer) |
242
273
  | `@soorya-u/better-auth-desktop/electron` | `electronDesktop()` plugin + `electronStorage()` (Electron main process) |
243
- | `@soorya-u/better-auth-desktop/web` | `forwardToDesktop()` + `webDesktop()` plugin (optional branded page) |
274
+ | `@soorya-u/better-auth-desktop/web` | `wrapForDesktop()`, `forwardToDesktop()`, `webDesktop()` plugin |
275
+ | `@soorya-u/better-auth-desktop/react` | `useSession()` React hook for the desktop bridge |
244
276
  | `@soorya-u/better-auth-desktop/client` | `desktopClient()` Better Auth client plugin |
245
277
  | `@soorya-u/better-auth-desktop/core` | shared types & utilities for custom adapters |
246
278
 
@@ -1,6 +1,6 @@
1
- import { n as AuthUser } from "../types-h9AEfSnq.mjs";
2
- import { n as DesktopClientPluginOptions } from "../client-De_zQzE1.mjs";
3
- import { n as keychainStorage, t as KeychainStorageOptions } from "../storage-DtgX_vmE.mjs";
1
+ import { n as AuthUser } from "../types-oAdD2Qlz.mjs";
2
+ import { n as DesktopClientPluginOptions } from "../client-DsuEHH54.mjs";
3
+ import { n as keychainStorage, t as KeychainStorageOptions } from "../storage-DbD61OuL.mjs";
4
4
  import * as _$_better_fetch_fetch0 from "@better-fetch/fetch";
5
5
  import { BetterFetch } from "@better-fetch/fetch";
6
6
  import { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
@@ -1,7 +1,7 @@
1
- import { t as PACKAGE_VERSION } from "../version-1hEssvcU.mjs";
2
- import { t as createDesktopCookieLayer } from "../client-DhRcoap2.mjs";
3
- import { n as startAuthFlow, r as fetchUserImage } from "../exchange-DCZ0j_T5.mjs";
4
- import { t as keychainStorage } from "../storage-BcwDmS-W.mjs";
1
+ import { t as PACKAGE_VERSION } from "../version-C6KnA6nF.mjs";
2
+ import { t as createDesktopCookieLayer } from "../client-CvnA-_xx.mjs";
3
+ import { i as fetchUserImage, r as startAuthFlow, t as buildAuthUrl } from "../exchange-D93Uwk6_.mjs";
4
+ import { t as keychainStorage } from "../storage-DR69vdzj.mjs";
5
5
  import { BrowserView, Utils } from "electrobun/bun";
6
6
  //#region src/adapters/electrobun.ts
7
7
  function createLoopbackAdapter(getSender, storage) {
@@ -109,6 +109,24 @@ const electrobunDesktop = (options) => {
109
109
  })
110
110
  });
111
111
  },
112
+ getAuthUrl: async ({ options: cfg }) => {
113
+ return await buildAuthUrl({
114
+ adapter,
115
+ $fetch,
116
+ clientOptions,
117
+ options,
118
+ cfg,
119
+ onAuthenticated: (user) => adapter.notifyRenderer({
120
+ type: "authenticated",
121
+ user
122
+ }),
123
+ onError: (error) => adapter.notifyRenderer({
124
+ type: "error",
125
+ error: error instanceof Error ? error : new Error(String(error)),
126
+ path: "/desktop/init-oauth-proxy"
127
+ })
128
+ });
129
+ },
112
130
  getUser: async () => {
113
131
  return await sanitizeUser((await $fetch("/get-session", {
114
132
  method: "GET",
@@ -1,5 +1,5 @@
1
- import { l as Storage } from "../types-h9AEfSnq.mjs";
2
- import { n as DesktopClientPluginOptions } from "../client-De_zQzE1.mjs";
1
+ import { l as Storage } from "../types-oAdD2Qlz.mjs";
2
+ import { n as DesktopClientPluginOptions } from "../client-DsuEHH54.mjs";
3
3
  import * as _$_better_fetch_fetch0 from "@better-fetch/fetch";
4
4
  import { BetterFetch } from "@better-fetch/fetch";
5
5
  import { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
@@ -7,6 +7,7 @@ import { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
7
7
  //#region src/adapters/electron.d.ts
8
8
  declare const ELECTRON_AUTH_CHANNELS: {
9
9
  readonly requestAuth: "desktop:requestAuth";
10
+ readonly getAuthUrl: "desktop:getAuthUrl";
10
11
  readonly getUser: "desktop:getUser";
11
12
  readonly signOut: "desktop:signOut";
12
13
  readonly getUserImage: "desktop:getUserImage";
@@ -1,11 +1,12 @@
1
- import { t as PACKAGE_VERSION } from "../version-1hEssvcU.mjs";
2
- import { t as createDesktopCookieLayer } from "../client-DhRcoap2.mjs";
3
- import { n as startAuthFlow, r as fetchUserImage } from "../exchange-DCZ0j_T5.mjs";
1
+ import { t as PACKAGE_VERSION } from "../version-C6KnA6nF.mjs";
2
+ import { t as createDesktopCookieLayer } from "../client-CvnA-_xx.mjs";
3
+ import { i as fetchUserImage, r as startAuthFlow, t as buildAuthUrl } from "../exchange-D93Uwk6_.mjs";
4
4
  import { Buffer } from "node:buffer";
5
5
  import { createServer } from "node:http";
6
6
  //#region src/adapters/electron.ts
7
7
  const ELECTRON_AUTH_CHANNELS = {
8
8
  requestAuth: "desktop:requestAuth",
9
+ getAuthUrl: "desktop:getAuthUrl",
9
10
  getUser: "desktop:getUser",
10
11
  signOut: "desktop:signOut",
11
12
  getUserImage: "desktop:getUserImage",
@@ -56,7 +57,7 @@ function createLoopbackAdapter(shell, getWindow, storage) {
56
57
  return {
57
58
  openExternal: (url) => shell.openExternal(url),
58
59
  serveLoopback(onRequest, opts) {
59
- return new Promise((resolve) => {
60
+ return new Promise((resolve, reject) => {
60
61
  const server = createServer(async (req, res) => {
61
62
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
62
63
  const query = {};
@@ -70,7 +71,16 @@ function createLoopbackAdapter(shell, getWindow, storage) {
70
71
  res.writeHead(out.status, out.headers);
71
72
  res.end(out.body);
72
73
  });
74
+ const timer = setTimeout(() => {
75
+ server.close();
76
+ reject(/* @__PURE__ */ new Error("Loopback server timed out waiting for port"));
77
+ }, 1e4);
78
+ server.on("error", (err) => {
79
+ clearTimeout(timer);
80
+ reject(err);
81
+ });
73
82
  server.listen(opts?.port ?? 0, "127.0.0.1", () => {
83
+ clearTimeout(timer);
74
84
  const address = server.address();
75
85
  resolve({
76
86
  port: typeof address === "object" && address ? address.port : 0,
@@ -156,6 +166,24 @@ const electronDesktop = (options) => {
156
166
  })
157
167
  });
158
168
  });
169
+ ipcMain.handle(ELECTRON_AUTH_CHANNELS.getAuthUrl, async (_event, cfg) => {
170
+ return await buildAuthUrl({
171
+ adapter,
172
+ $fetch,
173
+ clientOptions,
174
+ options,
175
+ cfg,
176
+ onAuthenticated: (user) => adapter.notifyRenderer({
177
+ type: "authenticated",
178
+ user
179
+ }),
180
+ onError: (error) => adapter.notifyRenderer({
181
+ type: "error",
182
+ error: error instanceof Error ? error : new Error(String(error)),
183
+ path: "/desktop/init-oauth-proxy"
184
+ })
185
+ });
186
+ });
159
187
  ipcMain.handle(ELECTRON_AUTH_CHANNELS.getUser, async () => {
160
188
  return await sanitizeUser((await $fetch("/get-session", {
161
189
  method: "GET",
@@ -192,6 +220,7 @@ const electronDesktop = (options) => {
192
220
  return () => {
193
221
  unsub?.();
194
222
  ipcMain.removeHandler(ELECTRON_AUTH_CHANNELS.requestAuth);
223
+ ipcMain.removeHandler(ELECTRON_AUTH_CHANNELS.getAuthUrl);
195
224
  ipcMain.removeHandler(ELECTRON_AUTH_CHANNELS.getUser);
196
225
  ipcMain.removeHandler(ELECTRON_AUTH_CHANNELS.signOut);
197
226
  ipcMain.removeHandler(ELECTRON_AUTH_CHANNELS.getUserImage);
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-1hEssvcU.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-C6KnA6nF.mjs";
2
2
  import { cookieNameRegex, parseSetCookieHeader } from "better-auth/cookies";
3
3
  //#region src/core/cookies.ts
4
4
  function getSetCookie(header, prevCookie) {
@@ -64,7 +64,9 @@ function createDesktopCookieLayer(options) {
64
64
  const setStored = (name, value) => {
65
65
  try {
66
66
  opts.storage.setItem(name, value);
67
- } catch {}
67
+ } catch (error) {
68
+ opts.onStorageError?.(error);
69
+ }
68
70
  };
69
71
  let store = null;
70
72
  let serverOrigin;
@@ -1,4 +1,4 @@
1
- import { i as DesktopClientOptions, l as Storage } from "./types-h9AEfSnq.mjs";
1
+ import { i as DesktopClientOptions, l as Storage } from "./types-oAdD2Qlz.mjs";
2
2
  import { BetterFetch, BetterFetchPlugin } from "@better-fetch/fetch";
3
3
  import { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
4
4
 
package/dist/client.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { i as DesktopClientOptions, l as Storage } from "./types-h9AEfSnq.mjs";
2
- import { a as desktopClient, n as DesktopClientPluginOptions, t as DesktopClientInternals } from "./client-De_zQzE1.mjs";
1
+ import { i as DesktopClientOptions, l as Storage } from "./types-oAdD2Qlz.mjs";
2
+ import { a as desktopClient, n as DesktopClientPluginOptions, t as DesktopClientInternals } from "./client-DsuEHH54.mjs";
3
3
  export { type DesktopClientInternals, type DesktopClientOptions, type DesktopClientPluginOptions, type Storage, desktopClient };
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as desktopClient } from "./client-DhRcoap2.mjs";
1
+ import { n as desktopClient } from "./client-CvnA-_xx.mjs";
2
2
  export { desktopClient };
@@ -1,6 +1,6 @@
1
- import { a as LoopbackRequest, c as RequestAuthOptions, i as DesktopClientOptions, l as Storage, n as AuthUser, o as LoopbackResponse, r as DesktopAdapter, s as LoopbackServer, t as AuthEvent } from "../types-h9AEfSnq.mjs";
2
- import { a as desktopClient, i as createDesktopCookieLayer, n as DesktopClientPluginOptions, r as DesktopCookieLayer, t as DesktopClientInternals } from "../client-De_zQzE1.mjs";
3
- import { n as keychainStorage, t as KeychainStorageOptions } from "../storage-DtgX_vmE.mjs";
4
- import { a as StartAuthFlowArgs, i as ExchangeTokenArgs, n as fetchUserImage, o as exchangeToken, r as normalizeUserOutput, s as startAuthFlow, t as FetchUserImageResult } from "../index-CP_2Wlut.mjs";
5
- import { a as isAllowedLoopbackPort, i as generateNonce, n as LOOPBACK_HOST, o as parseLoopbackUrl, r as buildLoopbackUrl, s as successPage, t as AllowedLoopbackPorts } from "../loopback-ClkAoS2u.mjs";
6
- export { AllowedLoopbackPorts, AuthEvent, AuthUser, DesktopAdapter, DesktopClientInternals, DesktopClientOptions, DesktopClientPluginOptions, DesktopCookieLayer, ExchangeTokenArgs, FetchUserImageResult, KeychainStorageOptions, LOOPBACK_HOST, LoopbackRequest, LoopbackResponse, LoopbackServer, RequestAuthOptions, StartAuthFlowArgs, Storage, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow, successPage };
1
+ import { a as LoopbackRequest, c as RequestAuthOptions, i as DesktopClientOptions, l as Storage, n as AuthUser, o as LoopbackResponse, r as DesktopAdapter, s as LoopbackServer, t as AuthEvent } from "../types-oAdD2Qlz.mjs";
2
+ import { a as desktopClient, i as createDesktopCookieLayer, n as DesktopClientPluginOptions, r as DesktopCookieLayer, t as DesktopClientInternals } from "../client-DsuEHH54.mjs";
3
+ import { n as keychainStorage, t as KeychainStorageOptions } from "../storage-DbD61OuL.mjs";
4
+ import { a as StartAuthFlowArgs, i as ExchangeTokenArgs, n as fetchUserImage, o as exchangeToken, r as normalizeUserOutput, s as startAuthFlow, t as FetchUserImageResult } from "../index-xd3h5rM3.mjs";
5
+ import { a as isAllowedLoopbackPort, i as generateNonce, n as LOOPBACK_HOST, o as parseLoopbackUrl, r as buildLoopbackUrl, t as AllowedLoopbackPorts } from "../loopback-Dec4O7Rx.mjs";
6
+ export { AllowedLoopbackPorts, AuthEvent, AuthUser, DesktopAdapter, DesktopClientInternals, DesktopClientOptions, DesktopClientPluginOptions, DesktopCookieLayer, ExchangeTokenArgs, FetchUserImageResult, KeychainStorageOptions, LOOPBACK_HOST, LoopbackRequest, LoopbackResponse, LoopbackServer, RequestAuthOptions, StartAuthFlowArgs, Storage, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow };
@@ -1,5 +1,5 @@
1
- import { n as desktopClient, t as createDesktopCookieLayer } from "../client-DhRcoap2.mjs";
2
- import { a as parseLoopbackUrl, i as isAllowedLoopbackPort, n as buildLoopbackUrl, o as successPage, r as generateNonce, t as LOOPBACK_HOST } from "../loopback-DTQgg1Za.mjs";
3
- import { i as normalizeUserOutput, n as startAuthFlow, r as fetchUserImage, t as exchangeToken } from "../exchange-DCZ0j_T5.mjs";
4
- import { t as keychainStorage } from "../storage-BcwDmS-W.mjs";
5
- export { LOOPBACK_HOST, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow, successPage };
1
+ import { n as desktopClient, t as createDesktopCookieLayer } from "../client-CvnA-_xx.mjs";
2
+ import { a as parseLoopbackUrl, i as isAllowedLoopbackPort, n as buildLoopbackUrl, r as generateNonce, t as LOOPBACK_HOST } from "../loopback-CNOfW74-.mjs";
3
+ import { a as normalizeUserOutput, i as fetchUserImage, n as exchangeToken, r as startAuthFlow } from "../exchange-D93Uwk6_.mjs";
4
+ import { t as keychainStorage } from "../storage-DR69vdzj.mjs";
5
+ export { LOOPBACK_HOST, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow };
@@ -1,4 +1,4 @@
1
- import { n as buildLoopbackUrl, o as successPage, r as generateNonce } from "./loopback-DTQgg1Za.mjs";
1
+ import { n as buildLoopbackUrl, r as generateNonce } from "./loopback-CNOfW74-.mjs";
2
2
  import { BetterAuthError } from "@better-auth/core/error";
3
3
  import { base64, base64Url } from "@better-auth/utils/base64";
4
4
  import { createHash } from "@better-auth/utils/hash";
@@ -28,22 +28,31 @@ async function fetchUserImage(baseURL, url) {
28
28
  } catch {
29
29
  return null;
30
30
  }
31
- const response = await fetch(resolvedUrl, {
32
- method: "GET",
33
- headers: { accept: "image/*" }
34
- });
35
- if (!response.ok) return null;
36
- const contentType = response.headers.get("content-type");
37
- if (!contentType?.startsWith("image/") || contentType.startsWith("image/svg")) return null;
38
- const contentLength = response.headers.get("content-length");
39
- if (contentLength && Number(contentLength) > DEFAULT_MAX_BYTES) return null;
40
- const buf = await response.arrayBuffer();
41
- const bytes = new Uint8Array(buf);
42
- if (bytes.byteLength > DEFAULT_MAX_BYTES) return null;
43
- return {
44
- bytes,
45
- mimeType: contentType.split(";")[0]?.trim() || "image/png"
46
- };
31
+ const controller = new AbortController();
32
+ const fetchTimer = setTimeout(() => controller.abort(), 1e4);
33
+ try {
34
+ const response = await fetch(resolvedUrl, {
35
+ method: "GET",
36
+ headers: { accept: "image/*" },
37
+ signal: controller.signal
38
+ });
39
+ if (!response.ok) return null;
40
+ const contentType = response.headers.get("content-type");
41
+ if (!contentType?.startsWith("image/") || contentType.startsWith("image/svg")) return null;
42
+ const contentLength = response.headers.get("content-length");
43
+ if (contentLength && Number(contentLength) > DEFAULT_MAX_BYTES) return null;
44
+ const buf = await response.arrayBuffer();
45
+ const bytes = new Uint8Array(buf);
46
+ if (bytes.byteLength > DEFAULT_MAX_BYTES) return null;
47
+ return {
48
+ bytes,
49
+ mimeType: contentType.split(";")[0]?.trim() || "image/png"
50
+ };
51
+ } catch {
52
+ return null;
53
+ } finally {
54
+ clearTimeout(fetchTimer);
55
+ }
47
56
  }
48
57
  function normalizeUserOutput(user, _options) {
49
58
  return { ...user };
@@ -100,10 +109,15 @@ function loopbackSuccessResponse(success) {
100
109
  headers: { location: success.redirectTo },
101
110
  body: ""
102
111
  };
103
- return {
112
+ if (typeof success === "string") return {
104
113
  status: 200,
105
114
  headers: { "content-type": "text/html; charset=utf-8" },
106
- body: success ?? successPage()
115
+ body: success
116
+ };
117
+ return {
118
+ status: 200,
119
+ headers: { "content-type": "text/plain; charset=utf-8" },
120
+ body: "Authentication complete. You can close this tab."
107
121
  };
108
122
  }
109
123
  async function sanitize(user, options) {
@@ -117,6 +131,10 @@ async function sanitize(user, options) {
117
131
  if (u !== null) u = normalizeUserOutput(u, options);
118
132
  return u;
119
133
  }
134
+ /**
135
+ * Decodes the redirect token, looks up the matching PKCE verifier, and
136
+ * exchanges it at `/desktop/token`. Called automatically by the loopback handler.
137
+ */
120
138
  async function exchangeToken({ $fetch, options, token, onAuthenticated, fetchOptions }) {
121
139
  const decoded = safeJSONParse(new TextDecoder().decode(base64Url.decode(decodeURIComponent(token))));
122
140
  const codeVerifier = decoded ? verifierStore.get(decoded.state) : void 0;
@@ -139,7 +157,7 @@ async function exchangeToken({ $fetch, options, token, onAuthenticated, fetchOpt
139
157
  }
140
158
  });
141
159
  }
142
- async function startAuthFlow({ adapter, $fetch, clientOptions, options, cfg, onAuthenticated, onError }) {
160
+ async function prepareAuthFlow({ adapter, $fetch, clientOptions, options, cfg, onAuthenticated, onError }) {
143
161
  const baseURL = getBaseURL(clientOptions?.baseURL, clientOptions?.basePath, void 0, true);
144
162
  if (!baseURL) throw new BetterAuthError("Base URL is required to start a desktop sign-in flow.");
145
163
  const state = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -157,40 +175,45 @@ async function startAuthFlow({ adapter, $fetch, clientOptions, options, cfg, onA
157
175
  server = null;
158
176
  verifierStore.delete(state);
159
177
  };
160
- server = await adapter.serveLoopback(async (req) => {
161
- if (req.path !== loopbackPath) return {
162
- status: 404,
163
- body: "Not found"
164
- };
165
- if (req.query.nonce !== nonce) return {
166
- status: 403,
167
- body: "Forbidden"
168
- };
169
- const token = req.query.token;
170
- if (!token) return {
171
- status: 400,
172
- body: "Missing token"
173
- };
174
- try {
175
- await exchangeToken({
176
- $fetch,
177
- options,
178
- token,
179
- onAuthenticated,
180
- fetchOptions: { throw: true }
181
- });
182
- } catch (error) {
183
- cleanup();
184
- onError?.(error);
185
- return {
186
- status: 500,
187
- headers: { "content-type": "text/plain; charset=utf-8" },
188
- body: "Sign-in failed. You can close this tab."
178
+ try {
179
+ server = await adapter.serveLoopback(async (req) => {
180
+ if (req.path !== loopbackPath) return {
181
+ status: 404,
182
+ body: "Not found"
189
183
  };
190
- }
191
- cleanup();
192
- return loopbackSuccessResponse(options.loopbackSuccess);
193
- }, { port: options.loopbackPort });
184
+ if (req.query.nonce !== nonce) return {
185
+ status: 403,
186
+ body: "Forbidden"
187
+ };
188
+ const token = req.query.token;
189
+ if (!token) return {
190
+ status: 400,
191
+ body: "Missing token"
192
+ };
193
+ try {
194
+ await exchangeToken({
195
+ $fetch,
196
+ options,
197
+ token,
198
+ onAuthenticated,
199
+ fetchOptions: { throw: true }
200
+ });
201
+ } catch (error) {
202
+ cleanup();
203
+ onError?.(error);
204
+ return {
205
+ status: 500,
206
+ headers: { "content-type": "text/plain; charset=utf-8" },
207
+ body: "Sign-in failed. You can close this tab."
208
+ };
209
+ }
210
+ cleanup();
211
+ return loopbackSuccessResponse(options.loopbackSuccess);
212
+ }, { port: options.loopbackPort });
213
+ } catch (error) {
214
+ verifierStore.delete(state);
215
+ throw error;
216
+ }
194
217
  const loopbackUrl = buildLoopbackUrl(server.port, loopbackPath, nonce);
195
218
  const url = new URL(`${baseURL}/desktop/init-oauth-proxy`);
196
219
  for (const [key, value] of Object.entries(cfg)) {
@@ -206,12 +229,28 @@ async function startAuthFlow({ adapter, $fetch, clientOptions, options, cfg, onA
206
229
  cleanup();
207
230
  onError?.(new BetterAuthError("Desktop sign-in timed out."));
208
231
  }, options.loopbackTimeout ?? DEFAULT_LOOPBACK_TIMEOUT);
232
+ return {
233
+ url: url.toString(),
234
+ cleanup
235
+ };
236
+ }
237
+ /**
238
+ * Returns the init-oauth-proxy URL without opening a browser.
239
+ * The loopback server starts listening in the background — use for copy-link flows.
240
+ */
241
+ async function buildAuthUrl(args) {
242
+ const { url } = await prepareAuthFlow(args);
243
+ return url;
244
+ }
245
+ /** Opens the init-oauth-proxy URL in the system browser and awaits the loopback callback. */
246
+ async function startAuthFlow(args) {
247
+ const { url, cleanup } = await prepareAuthFlow(args);
209
248
  try {
210
- await adapter.openExternal(url.toString());
249
+ await args.adapter.openExternal(url);
211
250
  } catch (error) {
212
251
  cleanup();
213
252
  throw error;
214
253
  }
215
254
  }
216
255
  //#endregion
217
- export { normalizeUserOutput as i, startAuthFlow as n, fetchUserImage as r, exchangeToken as t };
256
+ export { normalizeUserOutput as a, fetchUserImage as i, exchangeToken as n, startAuthFlow as r, buildAuthUrl as t };
@@ -1,4 +1,4 @@
1
- import { c as RequestAuthOptions, i as DesktopClientOptions, n as AuthUser, r as DesktopAdapter } from "./types-h9AEfSnq.mjs";
1
+ import { c as RequestAuthOptions, i as DesktopClientOptions, n as AuthUser, r as DesktopAdapter } from "./types-oAdD2Qlz.mjs";
2
2
  import { BetterFetch, CreateFetchOption } from "@better-fetch/fetch";
3
3
  import { BetterAuthClientOptions } from "@better-auth/core";
4
4
  import { User } from "@better-auth/core/db";
@@ -11,6 +11,10 @@ type ExchangeTokenArgs = {
11
11
  onAuthenticated: (user: AuthUser) => void;
12
12
  fetchOptions?: Omit<CreateFetchOption, "method"> | undefined;
13
13
  };
14
+ /**
15
+ * Decodes the redirect token, looks up the matching PKCE verifier, and
16
+ * exchanges it at `/desktop/token`. Called automatically by the loopback handler.
17
+ */
14
18
  declare function exchangeToken({
15
19
  $fetch,
16
20
  options,
@@ -40,15 +44,8 @@ type StartAuthFlowArgs = {
40
44
  onAuthenticated: (user: AuthUser) => void;
41
45
  onError?: (error: unknown) => void;
42
46
  };
43
- declare function startAuthFlow({
44
- adapter,
45
- $fetch,
46
- clientOptions,
47
- options,
48
- cfg,
49
- onAuthenticated,
50
- onError
51
- }: StartAuthFlowArgs): Promise<void>;
47
+ /** Opens the init-oauth-proxy URL in the system browser and awaits the loopback callback. */
48
+ declare function startAuthFlow(args: StartAuthFlowArgs): Promise<void>;
52
49
  //#endregion
53
50
  //#region src/core/user.d.ts
54
51
  type FetchUserImageResult = {
package/dist/index.d.mts CHANGED
@@ -1,10 +1,10 @@
1
- import { a as LoopbackRequest, c as RequestAuthOptions, i as DesktopClientOptions, l as Storage, n as AuthUser, o as LoopbackResponse, r as DesktopAdapter, s as LoopbackServer, t as AuthEvent } from "./types-h9AEfSnq.mjs";
2
- import { a as desktopClient, i as createDesktopCookieLayer, n as DesktopClientPluginOptions, r as DesktopCookieLayer, t as DesktopClientInternals } from "./client-De_zQzE1.mjs";
3
- import { n as keychainStorage, t as KeychainStorageOptions } from "./storage-DtgX_vmE.mjs";
4
- import { a as StartAuthFlowArgs, i as ExchangeTokenArgs, n as fetchUserImage, o as exchangeToken, r as normalizeUserOutput, s as startAuthFlow, t as FetchUserImageResult } from "./index-CP_2Wlut.mjs";
5
- import { a as isAllowedLoopbackPort, i as generateNonce, n as LOOPBACK_HOST, o as parseLoopbackUrl, r as buildLoopbackUrl, s as successPage, t as AllowedLoopbackPorts } from "./loopback-ClkAoS2u.mjs";
1
+ import { a as LoopbackRequest, c as RequestAuthOptions, i as DesktopClientOptions, l as Storage, n as AuthUser, o as LoopbackResponse, r as DesktopAdapter, s as LoopbackServer, t as AuthEvent } from "./types-oAdD2Qlz.mjs";
2
+ import { a as desktopClient, i as createDesktopCookieLayer, n as DesktopClientPluginOptions, r as DesktopCookieLayer, t as DesktopClientInternals } from "./client-DsuEHH54.mjs";
3
+ import { n as keychainStorage, t as KeychainStorageOptions } from "./storage-DbD61OuL.mjs";
4
+ import { a as StartAuthFlowArgs, i as ExchangeTokenArgs, n as fetchUserImage, o as exchangeToken, r as normalizeUserOutput, s as startAuthFlow, t as FetchUserImageResult } from "./index-xd3h5rM3.mjs";
5
+ import { a as isAllowedLoopbackPort, i as generateNonce, n as LOOPBACK_HOST, o as parseLoopbackUrl, r as buildLoopbackUrl, t as AllowedLoopbackPorts } from "./loopback-Dec4O7Rx.mjs";
6
6
 
7
7
  //#region src/version.d.ts
8
8
  declare const PACKAGE_VERSION: string;
9
9
  //#endregion
10
- export { AllowedLoopbackPorts, AuthEvent, AuthUser, DesktopAdapter, DesktopClientInternals, DesktopClientOptions, DesktopClientPluginOptions, DesktopCookieLayer, ExchangeTokenArgs, FetchUserImageResult, KeychainStorageOptions, LOOPBACK_HOST, LoopbackRequest, LoopbackResponse, LoopbackServer, PACKAGE_VERSION, RequestAuthOptions, StartAuthFlowArgs, Storage, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow, successPage };
10
+ export { AllowedLoopbackPorts, AuthEvent, AuthUser, DesktopAdapter, DesktopClientInternals, DesktopClientOptions, DesktopClientPluginOptions, DesktopCookieLayer, ExchangeTokenArgs, FetchUserImageResult, KeychainStorageOptions, LOOPBACK_HOST, LoopbackRequest, LoopbackResponse, LoopbackServer, PACKAGE_VERSION, RequestAuthOptions, StartAuthFlowArgs, Storage, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow };
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { t as PACKAGE_VERSION } from "./version-1hEssvcU.mjs";
2
- import { n as desktopClient, t as createDesktopCookieLayer } from "./client-DhRcoap2.mjs";
3
- import { a as parseLoopbackUrl, i as isAllowedLoopbackPort, n as buildLoopbackUrl, o as successPage, r as generateNonce, t as LOOPBACK_HOST } from "./loopback-DTQgg1Za.mjs";
4
- import { i as normalizeUserOutput, n as startAuthFlow, r as fetchUserImage, t as exchangeToken } from "./exchange-DCZ0j_T5.mjs";
5
- import { t as keychainStorage } from "./storage-BcwDmS-W.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-C6KnA6nF.mjs";
2
+ import { n as desktopClient, t as createDesktopCookieLayer } from "./client-CvnA-_xx.mjs";
3
+ import { a as parseLoopbackUrl, i as isAllowedLoopbackPort, n as buildLoopbackUrl, r as generateNonce, t as LOOPBACK_HOST } from "./loopback-CNOfW74-.mjs";
4
+ import { a as normalizeUserOutput, i as fetchUserImage, n as exchangeToken, r as startAuthFlow } from "./exchange-D93Uwk6_.mjs";
5
+ import { t as keychainStorage } from "./storage-DR69vdzj.mjs";
6
6
  import "./core/index.mjs";
7
- export { LOOPBACK_HOST, PACKAGE_VERSION, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow, successPage };
7
+ export { LOOPBACK_HOST, PACKAGE_VERSION, buildLoopbackUrl, createDesktopCookieLayer, desktopClient, exchangeToken, fetchUserImage, generateNonce, isAllowedLoopbackPort, keychainStorage, normalizeUserOutput, parseLoopbackUrl, startAuthFlow };
@@ -10,6 +10,10 @@ function buildLoopbackUrl(port, path, nonce) {
10
10
  url.searchParams.set("nonce", nonce);
11
11
  return url.toString();
12
12
  }
13
+ /**
14
+ * Returns `null` for anything other than a plain-http `127.0.0.1` URL so
15
+ * callers can safely reject untrusted redirect targets (never `localhost`, never https).
16
+ */
13
17
  function parseLoopbackUrl(raw, allowedPorts) {
14
18
  let url;
15
19
  try {
@@ -28,8 +32,5 @@ function isAllowedLoopbackPort(port, allowed) {
28
32
  if (Array.isArray(allowed)) return allowed.includes(port);
29
33
  return port >= allowed.min && port <= allowed.max;
30
34
  }
31
- function successPage(title = "Signed in", message = "You can close this tab and return to the app.") {
32
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title></head><body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;text-align:center"><main><h1 style="font-size:1.25rem;margin:0 0 .5rem">${title}</h1><p style="margin:0;opacity:.7">${message}</p></main></body></html>`;
33
- }
34
35
  //#endregion
35
- export { parseLoopbackUrl as a, isAllowedLoopbackPort as i, buildLoopbackUrl as n, successPage as o, generateNonce as r, LOOPBACK_HOST as t };
36
+ export { parseLoopbackUrl as a, isAllowedLoopbackPort as i, buildLoopbackUrl as n, generateNonce as r, LOOPBACK_HOST as t };
@@ -6,8 +6,11 @@ type AllowedLoopbackPorts = number[] | {
6
6
  };
7
7
  declare function generateNonce(): string;
8
8
  declare function buildLoopbackUrl(port: number, path: string, nonce: string): string;
9
+ /**
10
+ * Returns `null` for anything other than a plain-http `127.0.0.1` URL so
11
+ * callers can safely reject untrusted redirect targets (never `localhost`, never https).
12
+ */
9
13
  declare function parseLoopbackUrl(raw: string, allowedPorts?: AllowedLoopbackPorts): URL | null;
10
14
  declare function isAllowedLoopbackPort(port: number, allowed: AllowedLoopbackPorts): boolean;
11
- declare function successPage(title?: string, message?: string): string;
12
15
  //#endregion
13
- export { isAllowedLoopbackPort as a, generateNonce as i, LOOPBACK_HOST as n, parseLoopbackUrl as o, buildLoopbackUrl as r, successPage as s, AllowedLoopbackPorts as t };
16
+ export { isAllowedLoopbackPort as a, generateNonce as i, LOOPBACK_HOST as n, parseLoopbackUrl as o, buildLoopbackUrl as r, AllowedLoopbackPorts as t };
@@ -0,0 +1,14 @@
1
+ import { n as AuthUser } from "./types-oAdD2Qlz.mjs";
2
+ import { t as AuthBridges } from "./schema-CE7bJ4bb.mjs";
3
+
4
+ //#region src/react.d.ts
5
+ /** React hook that subscribes to the desktop bridge user session. Must be called from a component or another hook. */
6
+ declare function useSession<TUser extends AuthUser = AuthUser>(bridge: AuthBridges<TUser> | null): {
7
+ data: {
8
+ user: TUser;
9
+ } | null;
10
+ isPending: boolean;
11
+ error: unknown;
12
+ };
13
+ //#endregion
14
+ export { useSession };
package/dist/react.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from "react";
2
+ //#region src/react.ts
3
+ /** React hook that subscribes to the desktop bridge user session. Must be called from a component or another hook. */
4
+ function useSession(bridge) {
5
+ const [data, setData] = useState(null);
6
+ const [isPending, setIsPending] = useState(true);
7
+ useEffect(() => {
8
+ setData(null);
9
+ setIsPending(true);
10
+ if (!bridge) {
11
+ setIsPending(false);
12
+ return;
13
+ }
14
+ let settled = false;
15
+ const settle = (user) => {
16
+ if (!settled) {
17
+ settled = true;
18
+ setIsPending(false);
19
+ }
20
+ setData(user ? { user } : null);
21
+ };
22
+ return bridge.watchUser(settle);
23
+ }, [bridge]);
24
+ return {
25
+ data,
26
+ isPending,
27
+ error: null
28
+ };
29
+ }
30
+ //#endregion
31
+ export { useSession };
@@ -1,26 +1,8 @@
1
- import { c as RequestAuthOptions } from "../types-h9AEfSnq.mjs";
2
- import { BetterFetchError } from "@better-fetch/fetch";
3
- import { User } from "@better-auth/core/db";
1
+ import { n as AuthUser } from "../types-oAdD2Qlz.mjs";
2
+ import { t as AuthBridges } from "../schema-CE7bJ4bb.mjs";
4
3
 
5
- //#region src/rpc/schema.d.ts
6
- type AuthBridges = {
7
- getUser(): Promise<(User & Record<string, any>) | null>;
8
- requestAuth(options: RequestAuthOptions): Promise<void>;
9
- signOut(): Promise<void>;
10
- getUserImage(url: string): Promise<{
11
- dataUrl: string | null;
12
- }>;
13
- onAuthenticated(callback: (user: User & Record<string, any>) => void): () => void;
14
- onUserUpdated(callback: (user: (User & Record<string, any>) | null) => void): () => void;
15
- onAuthError(callback: (context: {
16
- error: BetterFetchError | {
17
- message: string;
18
- };
19
- path?: string;
20
- }) => void): () => void;
21
- };
22
- //#endregion
23
4
  //#region src/rpc/webview.d.ts
24
- declare function defineAuthWebviewRPC(): AuthBridges;
5
+ /** Initializes the Electrobun webview RPC and returns the desktop bridge. */
6
+ declare function defineAuthWebviewRPC<TUser extends AuthUser = AuthUser>(): AuthBridges<TUser>;
25
7
  //#endregion
26
8
  export { defineAuthWebviewRPC };
@@ -278,18 +278,7 @@ var Electroview = class {
278
278
  };
279
279
  //#endregion
280
280
  //#region src/rpc/webview.ts
281
- /**
282
- * Renderer-side auth bridge for the Electrobun WebView. Call once at the top of
283
- * your webview entry, then use the returned bridge:
284
- *
285
- * ```ts
286
- * import { defineAuthWebviewRPC } from "@soorya-u/better-auth-desktop/rpc/webview";
287
- * export const auth = defineAuthWebviewRPC();
288
- *
289
- * auth.onAuthenticated((user) => navigate("/threads"));
290
- * await auth.requestAuth({ provider: "github" });
291
- * ```
292
- */
281
+ /** Initializes the Electrobun webview RPC and returns the desktop bridge. */
293
282
  function defineAuthWebviewRPC() {
294
283
  const rpc = Electroview.defineRPC({
295
284
  maxRequestTime: 3e4,
@@ -299,25 +288,70 @@ function defineAuthWebviewRPC() {
299
288
  }
300
289
  });
301
290
  new Electroview({ rpc });
291
+ const teardowns = /* @__PURE__ */ new Set();
292
+ const track = (off) => {
293
+ teardowns.add(off);
294
+ return () => {
295
+ off();
296
+ teardowns.delete(off);
297
+ };
298
+ };
302
299
  return {
303
300
  getUser: () => rpc.request.getUser({}),
301
+ getSession: () => rpc.request.getUser({}).then((user) => ({
302
+ data: user ? { user } : null,
303
+ error: null
304
+ })).catch((error) => ({
305
+ data: null,
306
+ error
307
+ })),
308
+ watchUser: (callback) => {
309
+ let disposed = false;
310
+ let sawEvent = false;
311
+ const apply = (user) => {
312
+ sawEvent = true;
313
+ if (!disposed) callback(user);
314
+ };
315
+ const authHandler = (user) => apply(user);
316
+ const updateHandler = (user) => apply(user);
317
+ rpc.addMessageListener("onAuthenticated", authHandler);
318
+ rpc.addMessageListener("onUserUpdated", updateHandler);
319
+ rpc.request.getUser({}).then((user) => {
320
+ if (!disposed && !sawEvent) callback(user);
321
+ }).catch(() => {
322
+ if (!disposed && !sawEvent) callback(null);
323
+ });
324
+ const cleanup = () => {
325
+ disposed = true;
326
+ rpc.removeMessageListener("onAuthenticated", authHandler);
327
+ rpc.removeMessageListener("onUserUpdated", updateHandler);
328
+ teardowns.delete(cleanup);
329
+ };
330
+ teardowns.add(cleanup);
331
+ return cleanup;
332
+ },
304
333
  requestAuth: (options) => rpc.request.requestAuth({ options }),
334
+ getAuthUrl: (options) => rpc.request.getAuthUrl({ options }),
305
335
  signOut: () => rpc.request.signOut({}),
306
336
  getUserImage: (url) => rpc.request.getUserImage({ url }),
307
337
  onAuthenticated: (callback) => {
308
338
  const handler = (user) => callback(user);
309
339
  rpc.addMessageListener("onAuthenticated", handler);
310
- return () => rpc.removeMessageListener("onAuthenticated", handler);
340
+ return track(() => rpc.removeMessageListener("onAuthenticated", handler));
311
341
  },
312
342
  onUserUpdated: (callback) => {
313
343
  const handler = (user) => callback(user);
314
344
  rpc.addMessageListener("onUserUpdated", handler);
315
- return () => rpc.removeMessageListener("onUserUpdated", handler);
345
+ return track(() => rpc.removeMessageListener("onUserUpdated", handler));
316
346
  },
317
347
  onAuthError: (callback) => {
318
348
  const handler = (ctx) => callback(ctx);
319
349
  rpc.addMessageListener("onAuthError", handler);
320
- return () => rpc.removeMessageListener("onAuthError", handler);
350
+ return track(() => rpc.removeMessageListener("onAuthError", handler));
351
+ },
352
+ destroy: () => {
353
+ for (const off of teardowns) off();
354
+ teardowns.clear();
321
355
  }
322
356
  };
323
357
  }
@@ -0,0 +1,33 @@
1
+ import { c as RequestAuthOptions, n as AuthUser } from "./types-oAdD2Qlz.mjs";
2
+ import { BetterFetchError } from "@better-fetch/fetch";
3
+ import { User } from "@better-auth/core/db";
4
+
5
+ //#region src/rpc/schema.d.ts
6
+ type SessionResult<TUser> = {
7
+ data: {
8
+ user: TUser;
9
+ } | null;
10
+ error: unknown;
11
+ };
12
+ type AuthBridges<TUser extends AuthUser = AuthUser> = {
13
+ getUser(): Promise<TUser | null>;
14
+ getSession(): Promise<SessionResult<TUser>>;
15
+ watchUser(callback: (user: TUser | null) => void): () => void;
16
+ requestAuth(options: RequestAuthOptions): Promise<void>;
17
+ getAuthUrl(options: RequestAuthOptions): Promise<string>;
18
+ signOut(): Promise<void>;
19
+ getUserImage(url: string): Promise<{
20
+ dataUrl: string | null;
21
+ }>;
22
+ onAuthenticated(callback: (user: TUser) => void): () => void;
23
+ onUserUpdated(callback: (user: TUser | null) => void): () => void;
24
+ onAuthError(callback: (context: {
25
+ error: BetterFetchError | {
26
+ message: string;
27
+ };
28
+ path?: string;
29
+ }) => void): () => void;
30
+ destroy(): void;
31
+ };
32
+ //#endregion
33
+ export { AuthBridges as t };
@@ -1,4 +1,4 @@
1
- import { t as AllowedLoopbackPorts } from "../loopback-ClkAoS2u.mjs";
1
+ import { t as AllowedLoopbackPorts } from "../loopback-Dec4O7Rx.mjs";
2
2
  import * as _$better_auth0 from "better-auth";
3
3
  import { BetterAuthPlugin } from "better-auth";
4
4
 
@@ -1,5 +1,5 @@
1
- import { t as PACKAGE_VERSION } from "../version-1hEssvcU.mjs";
2
- import { a as parseLoopbackUrl } from "../loopback-DTQgg1Za.mjs";
1
+ import { t as PACKAGE_VERSION } from "../version-C6KnA6nF.mjs";
2
+ import { a as parseLoopbackUrl } from "../loopback-CNOfW74-.mjs";
3
3
  import { setSessionCookie } from "better-auth/cookies";
4
4
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
5
5
  import { base64Url } from "@better-auth/utils/base64";
@@ -16,7 +16,7 @@ async function keychainStorage(opts = {}) {
16
16
  service,
17
17
  name: account,
18
18
  value: JSON.stringify(cache)
19
- }).catch(() => void 0);
19
+ }).catch((error) => opts.onError?.(error));
20
20
  };
21
21
  return {
22
22
  getItem: (name) => cache[name] ?? null,
@@ -0,0 +1,11 @@
1
+ import { l as Storage } from "./types-oAdD2Qlz.mjs";
2
+
3
+ //#region src/core/storage.d.ts
4
+ type KeychainStorageOptions = {
5
+ service?: string | undefined;
6
+ account?: string | undefined; /** Called when a keychain write fails. Use this to surface the error to the user or log it. */
7
+ onError?: (error: unknown) => void;
8
+ };
9
+ declare function keychainStorage(opts?: KeychainStorageOptions): Promise<Storage>;
10
+ //#endregion
11
+ export { keychainStorage as n, KeychainStorageOptions as t };
@@ -62,14 +62,10 @@ type DesktopClientOptions = {
62
62
  cookiePrefix?: string | string[];
63
63
  disableCache?: boolean;
64
64
  sanitizeUser?: (user: AuthUser) => Awaitable<AuthUser>;
65
- /**
66
- * What the loopback shows after a successful exchange. A string is used as the
67
- * HTML body; an object can redirect the browser to your own page instead.
68
- * Defaults to a built-in "you can close this tab" page.
69
- */
70
65
  loopbackSuccess?: string | {
71
66
  redirectTo: string;
72
- };
67
+ }; /** Called when a keychain/storage write fails. Use this to surface the error to the user or log it. */
68
+ onStorageError?: (error: unknown) => void;
73
69
  };
74
70
  //#endregion
75
71
  export { LoopbackRequest as a, RequestAuthOptions as c, DesktopClientOptions as i, Storage as l, AuthUser as n, LoopbackResponse as o, DesktopAdapter as r, LoopbackServer as s, AuthEvent as t };
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "0.1.0";
3
+ const PACKAGE_VERSION = "0.1.2";
4
4
  //#endregion
5
5
  export { PACKAGE_VERSION as t };
@@ -1,32 +1,50 @@
1
- //#region src/web/forward-to-desktop.d.ts
1
+ import { n as AuthUser } from "../types-oAdD2Qlz.mjs";
2
+ import { t as AuthBridges } from "../schema-CE7bJ4bb.mjs";
3
+
4
+ //#region src/web/forward.d.ts
2
5
  type ForwardToDesktopOptions = {
3
- /** Must match the server plugin's `hashKey`. @default "token" */hashKey?: string;
6
+ hashKey?: string;
7
+ };
8
+ type ForwardToDesktopResult = {
9
+ success: true;
10
+ error: null;
11
+ } | {
12
+ success: false;
13
+ error: Error;
4
14
  };
5
15
  /**
6
- * Opt-in successor to the old scheme-based `forwardCallback`. On a branded
7
- * `webCallbackUrl` page, reads `#token=…&loopback=…` from the fragment and
8
- * performs a **top-level navigation** to the desktop loopback
9
- * (`http://127.0.0.1:<port>/…?token=…`) — no CORS / PNA / mixed-content.
10
- *
11
- * Returns `false` (a no-op) outside a browser or when the fragment is absent,
12
- * so it is safe to call unconditionally on the callback page.
13
- */
14
- declare function forwardToDesktop(options?: ForwardToDesktopOptions): boolean;
15
- /**
16
- * Better Auth client plugin wrapping {@link forwardToDesktop}, for consumers
17
- * that prefer the `createAuthClient({ plugins: [...] })` pattern:
18
- *
19
- * ```ts
20
- * const authClient = createAuthClient({ plugins: [webDesktop()] });
21
- * authClient.forwardToDesktop();
22
- * ```
16
+ * Reads `#token=…&loopback=…` from the URL fragment and navigates to the
17
+ * desktop loopback URL. Returns `success: false` outside a browser or when
18
+ * the fragment is absent.
23
19
  */
20
+ declare function forwardToDesktop(options?: ForwardToDesktopOptions): ForwardToDesktopResult;
21
+ /** Better-auth client plugin that exposes {@link forwardToDesktop} as a client action. */
24
22
  declare const webDesktop: (options?: ForwardToDesktopOptions) => {
25
23
  id: "desktop-web";
26
24
  version: string;
27
25
  getActions: () => {
28
- forwardToDesktop: () => boolean;
26
+ forwardToDesktop: () => ForwardToDesktopResult;
29
27
  };
30
28
  };
31
29
  //#endregion
32
- export { type ForwardToDesktopOptions, forwardToDesktop, webDesktop };
30
+ //#region src/web/wrap.d.ts
31
+ /**
32
+ * Session hook compatible with {@link wrapForDesktop}'s third parameter.
33
+ * Use this to type a custom framework hook (Vue, Svelte, etc.) before passing it in.
34
+ */
35
+ type UseSessionFn<TUser extends AuthUser = AuthUser> = (bridge: AuthBridges<TUser> | null) => {
36
+ data: {
37
+ user: TUser;
38
+ } | null;
39
+ isPending: boolean;
40
+ error: unknown;
41
+ };
42
+ /**
43
+ * Proxy-wraps a better-auth client so that `getSession`, `signOut`, and
44
+ * `signIn.social` are delegated to the desktop bridge. Pass an optional
45
+ * `useSessionFn` to also intercept `useSession`. Returns `base` unchanged
46
+ * when `bridge` is `null`.
47
+ */
48
+ declare function wrapForDesktop<T extends object, TUser extends AuthUser = AuthUser>(base: T, bridge: AuthBridges<TUser> | null, useSessionFn?: UseSessionFn<TUser>): T;
49
+ //#endregion
50
+ export { type ForwardToDesktopOptions, type UseSessionFn, forwardToDesktop, webDesktop, wrapForDesktop };
@@ -1,41 +1,76 @@
1
- import { t as PACKAGE_VERSION } from "../version-1hEssvcU.mjs";
2
- import { a as parseLoopbackUrl } from "../loopback-DTQgg1Za.mjs";
3
- //#region src/web/forward-to-desktop.ts
1
+ import { t as PACKAGE_VERSION } from "../version-C6KnA6nF.mjs";
2
+ import { a as parseLoopbackUrl } from "../loopback-CNOfW74-.mjs";
3
+ //#region src/web/forward.ts
4
4
  /**
5
- * Opt-in successor to the old scheme-based `forwardCallback`. On a branded
6
- * `webCallbackUrl` page, reads `#token=…&loopback=…` from the fragment and
7
- * performs a **top-level navigation** to the desktop loopback
8
- * (`http://127.0.0.1:<port>/…?token=…`) — no CORS / PNA / mixed-content.
9
- *
10
- * Returns `false` (a no-op) outside a browser or when the fragment is absent,
11
- * so it is safe to call unconditionally on the callback page.
5
+ * Reads `#token=…&loopback=…` from the URL fragment and navigates to the
6
+ * desktop loopback URL. Returns `success: false` outside a browser or when
7
+ * the fragment is absent.
12
8
  */
13
9
  function forwardToDesktop(options) {
14
- if (typeof window === "undefined" || !window) return false;
10
+ if (typeof window === "undefined" || !window) return {
11
+ success: false,
12
+ error: /* @__PURE__ */ new Error("Not running in a browser")
13
+ };
15
14
  const hashKey = options?.hashKey ?? "token";
16
15
  const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
17
16
  const token = params.get(hashKey);
18
17
  const loopback = params.get("loopback");
19
- if (!token || !loopback) return false;
20
- const target = parseLoopbackUrl(decodeURIComponent(loopback));
21
- if (!target) return false;
18
+ if (!token || !loopback) return {
19
+ success: false,
20
+ error: /* @__PURE__ */ new Error("Missing token or loopback in URL fragment")
21
+ };
22
+ const target = parseLoopbackUrl(loopback);
23
+ if (!target) return {
24
+ success: false,
25
+ error: /* @__PURE__ */ new Error("Invalid loopback URL")
26
+ };
22
27
  target.searchParams.set(hashKey, token);
23
28
  window.location.replace(target.toString());
24
- return true;
29
+ return {
30
+ success: true,
31
+ error: null
32
+ };
25
33
  }
26
- /**
27
- * Better Auth client plugin wrapping {@link forwardToDesktop}, for consumers
28
- * that prefer the `createAuthClient({ plugins: [...] })` pattern:
29
- *
30
- * ```ts
31
- * const authClient = createAuthClient({ plugins: [webDesktop()] });
32
- * authClient.forwardToDesktop();
33
- * ```
34
- */
34
+ /** Better-auth client plugin that exposes {@link forwardToDesktop} as a client action. */
35
35
  const webDesktop = (options) => ({
36
36
  id: "desktop-web",
37
37
  version: PACKAGE_VERSION,
38
38
  getActions: () => ({ forwardToDesktop: () => forwardToDesktop(options) })
39
39
  });
40
40
  //#endregion
41
- export { forwardToDesktop, webDesktop };
41
+ //#region src/web/wrap.ts
42
+ /**
43
+ * Proxy-wraps a better-auth client so that `getSession`, `signOut`, and
44
+ * `signIn.social` are delegated to the desktop bridge. Pass an optional
45
+ * `useSessionFn` to also intercept `useSession`. Returns `base` unchanged
46
+ * when `bridge` is `null`.
47
+ */
48
+ function wrapForDesktop(base, bridge, useSessionFn) {
49
+ if (!bridge) return base;
50
+ return new Proxy(base, { get(target, prop, receiver) {
51
+ if (prop === "useSession") {
52
+ if (useSessionFn) return () => useSessionFn(bridge);
53
+ return Reflect.get(target, prop, receiver);
54
+ }
55
+ if (prop === "getSession") return () => bridge.getSession();
56
+ if (prop === "signOut") return () => bridge.signOut();
57
+ if (prop !== "signIn") return Reflect.get(target, prop, receiver);
58
+ const signIn = Reflect.get(target, prop, receiver);
59
+ return new Proxy(signIn, { get(signInTarget, signInProp, signInReceiver) {
60
+ if (signInProp !== "social") return Reflect.get(signInTarget, signInProp, signInReceiver);
61
+ return (params) => {
62
+ const { disableRedirect, ...rest } = params;
63
+ if (disableRedirect) return bridge.getAuthUrl(rest).then((url) => ({
64
+ data: {
65
+ url,
66
+ redirect: false
67
+ },
68
+ error: null
69
+ }));
70
+ return bridge.requestAuth(rest);
71
+ };
72
+ } });
73
+ } });
74
+ }
75
+ //#endregion
76
+ export { forwardToDesktop, webDesktop, wrapForDesktop };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@soorya-u/better-auth-desktop",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Loopback-based desktop auth for Better Auth. Framework-agnostic server + core with thin Electrobun and Electron adapters; OAuth hand-off via a 127.0.0.1 loopback navigation (no custom URL scheme).",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "homepage": "https://github.com/soorya-u/better-auth-electrobun",
7
+ "homepage": "https://github.com/soorya-u/better-auth-desktop",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/soorya-u/better-auth-electrobun.git"
10
+ "url": "git+https://github.com/soorya-u/better-auth-desktop.git"
11
11
  },
12
12
  "keywords": [
13
13
  "electrobun",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "sideEffects": false,
25
25
  "scripts": {
26
+ "prepare": "lefthook install",
26
27
  "build": "tsdown",
27
28
  "dev": "tsdown --watch",
28
29
  "lint:package": "publint run --strict --pack false",
@@ -80,6 +81,11 @@
80
81
  "dev-source": "./src/rpc/webview.ts",
81
82
  "types": "./dist/rpc/webview.d.mts",
82
83
  "default": "./dist/rpc/webview.mjs"
84
+ },
85
+ "./react": {
86
+ "dev-source": "./src/react.ts",
87
+ "types": "./dist/react.d.mts",
88
+ "default": "./dist/react.mjs"
83
89
  }
84
90
  },
85
91
  "typesVersions": {
@@ -107,20 +113,23 @@
107
113
  ],
108
114
  "rpc/webview": [
109
115
  "./dist/rpc/webview.d.mts"
116
+ ],
117
+ "react": [
118
+ "./dist/react.d.mts"
110
119
  ]
111
120
  }
112
121
  },
113
- "dependencies": {
114
- "zod": "^4.3.6"
115
- },
116
122
  "devDependencies": {
123
+ "@arethetypeswrong/cli": "^0.18.4",
117
124
  "@biomejs/biome": "^2.5.1",
118
125
  "@types/better-sqlite3": "^7.6.13",
119
126
  "@types/bun": "latest",
127
+ "@types/react": "^19.2.17",
120
128
  "better-auth": "^1.6.21",
121
129
  "better-call": "1.3.7",
122
130
  "better-sqlite3": "^12.11.1",
123
131
  "electrobun": "^1.18.1",
132
+ "lefthook": "^2.1.9",
124
133
  "publint": "^0.3.12",
125
134
  "tsdown": "^0.21.1",
126
135
  "typescript": "^5.9.3",
@@ -128,6 +137,7 @@
128
137
  },
129
138
  "peerDependencies": {
130
139
  "@better-auth/core": "^1.6.21",
140
+ "react": ">=18.0.0",
131
141
  "@better-auth/utils": "^0.4.2",
132
142
  "@better-fetch/fetch": "^1.3.1",
133
143
  "better-auth": "^1.6.21",
@@ -145,6 +155,9 @@
145
155
  },
146
156
  "electron-store": {
147
157
  "optional": true
158
+ },
159
+ "react": {
160
+ "optional": true
148
161
  }
149
162
  },
150
163
  "engines": {
@@ -1,10 +0,0 @@
1
- import { l as Storage } from "./types-h9AEfSnq.mjs";
2
-
3
- //#region src/core/storage.d.ts
4
- type KeychainStorageOptions = {
5
- service?: string | undefined;
6
- account?: string | undefined;
7
- };
8
- declare function keychainStorage(opts?: KeychainStorageOptions): Promise<Storage>;
9
- //#endregion
10
- export { keychainStorage as n, KeychainStorageOptions as t };