@pylonsync/next 0.3.139 → 0.3.140

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/server.ts +105 -39
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.139",
6
+ "version": "0.3.140",
7
7
  "type": "module",
8
8
  "description": "Next.js helpers for Pylon — cookie-based auth gate, server-side session helpers, and reusable client hooks.",
9
9
  "exports": {
@@ -18,8 +18,8 @@
18
18
  "check": "tsc -p tsconfig.json --noEmit"
19
19
  },
20
20
  "dependencies": {
21
- "@pylonsync/sdk": "0.3.139",
22
- "@pylonsync/react": "0.3.139"
21
+ "@pylonsync/sdk": "0.3.140",
22
+ "@pylonsync/react": "0.3.140"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "next": ">=16.0.0",
package/src/server.ts CHANGED
@@ -34,14 +34,40 @@ export type PylonAuth = {
34
34
  *
35
35
  * `loginUrl` is where {@link PylonServer.requireAuth} / {@link
36
36
  * PylonServer.requireMe} redirect when the session is missing.
37
+ *
38
+ * `timeoutMs` caps every server-side fetch to the control plane.
39
+ * Default 10s. The point is to fail loudly during a backend hiccup
40
+ * (machine restart, Fly proxy stall) rather than letting the React
41
+ * server render block until Vercel's 30s edge timeout. Pair with a
42
+ * route-level `error.tsx` so users see "server unavailable, retry"
43
+ * instead of a blank-tab hang.
37
44
  */
38
45
  export type PylonServerConfig = {
39
46
  cookieName: string;
40
47
  target?: string;
41
48
  getMeFn?: string;
42
49
  loginUrl?: string;
50
+ timeoutMs?: number;
43
51
  };
44
52
 
53
+ /**
54
+ * Thrown by `createPylonServer` helpers when the control plane is
55
+ * unreachable — network error, fetch timeout, or any non-HTTP error.
56
+ * Distinct from `ApiError` (which carries an HTTP status from a
57
+ * response that DID come back). `requireMe`/`requireAuth` rethrow this
58
+ * instead of redirecting to /login so the route's `error.tsx` can
59
+ * render a "server unavailable, retry" UI without misleadingly
60
+ * suggesting the user signed out.
61
+ */
62
+ export class PylonUnreachableError extends Error {
63
+ readonly cause?: unknown;
64
+ constructor(message: string, cause?: unknown) {
65
+ super(message);
66
+ this.name = "PylonUnreachableError";
67
+ this.cause = cause;
68
+ }
69
+ }
70
+
45
71
  /**
46
72
  * Bound server helpers — built once per app via {@link createPylonServer}
47
73
  * and used everywhere. Eliminates the per-call cookieName / target
@@ -107,9 +133,45 @@ export function createPylonServer(config: PylonServerConfig): PylonServer {
107
133
  const targetOpt = config.target;
108
134
  const getMeFn = config.getMeFn ?? "getMe";
109
135
  const loginUrl = config.loginUrl ?? "/login";
136
+ const timeoutMs = config.timeoutMs ?? 10_000;
110
137
 
111
138
  const target = (): string => resolveTarget(targetOpt);
112
139
 
140
+ /**
141
+ * Wrap a control-plane fetch with the configured timeout, normalizing
142
+ * any non-HTTP failure (abort, DNS, connection-refused, mid-stream
143
+ * disconnect during a machine restart) into {@link PylonUnreachableError}.
144
+ * Callers that need to differentiate "server hiccup" from "real 4xx"
145
+ * key off the error type.
146
+ */
147
+ async function fetchWithTimeout(
148
+ url: string,
149
+ init: RequestInit = {},
150
+ ): Promise<Response> {
151
+ const ac = new AbortController();
152
+ const t = setTimeout(() => ac.abort(), timeoutMs);
153
+ try {
154
+ return await fetch(url, {
155
+ cache: "no-store",
156
+ ...init,
157
+ signal: ac.signal,
158
+ });
159
+ } catch (err) {
160
+ if (err instanceof Error && err.name === "AbortError") {
161
+ throw new PylonUnreachableError(
162
+ `Pylon control plane timed out after ${timeoutMs}ms (${url})`,
163
+ err,
164
+ );
165
+ }
166
+ throw new PylonUnreachableError(
167
+ `Pylon control plane unreachable (${url}): ${err instanceof Error ? err.message : String(err)}`,
168
+ err,
169
+ );
170
+ } finally {
171
+ clearTimeout(t);
172
+ }
173
+ }
174
+
113
175
  async function readSession(): Promise<{
114
176
  header: string;
115
177
  value: string;
@@ -123,26 +185,19 @@ export function createPylonServer(config: PylonServerConfig): PylonServer {
123
185
  async function getAuth(): Promise<PylonAuth | null> {
124
186
  const session = await readSession();
125
187
  if (!session) return null;
126
- const auth = await fetch(`${target()}/api/auth/me`, {
188
+ // Network failures bubble as PylonUnreachableError — DON'T swallow
189
+ // them as null. Older code did `.catch(() => ({}))` which made a
190
+ // hung backend look like an unauthenticated session, so
191
+ // `requireAuth` redirected to /login when the real fix was "retry".
192
+ // The route's `error.tsx` boundary handles the throw correctly.
193
+ const res = await fetchWithTimeout(`${target()}/api/auth/me`, {
127
194
  headers: { cookie: session.header },
128
- cache: "no-store",
129
- })
130
- .then(
131
- (r) =>
132
- r.json() as Promise<{
133
- user_id?: string;
134
- tenant_id?: string | null;
135
- is_admin?: boolean;
136
- }>,
137
- )
138
- .catch(
139
- () =>
140
- ({}) as {
141
- user_id?: string;
142
- tenant_id?: string | null;
143
- is_admin?: boolean;
144
- },
145
- );
195
+ });
196
+ const auth = (await res.json()) as {
197
+ user_id?: string;
198
+ tenant_id?: string | null;
199
+ is_admin?: boolean;
200
+ };
146
201
  if (!auth.user_id) return null;
147
202
  return {
148
203
  userId: auth.user_id,
@@ -165,8 +220,7 @@ export function createPylonServer(config: PylonServerConfig): PylonServer {
165
220
  const session = await readSession();
166
221
  const headers = new Headers(init.headers);
167
222
  if (session) headers.set("cookie", session.header);
168
- return fetch(`${target()}${path}`, {
169
- cache: "no-store",
223
+ return fetchWithTimeout(`${target()}${path}`, {
170
224
  ...init,
171
225
  headers,
172
226
  });
@@ -191,10 +245,15 @@ export function createPylonServer(config: PylonServerConfig): PylonServer {
191
245
  // Providers are env-derived on the control plane; they don't
192
246
  // change per-request but DO change across deploys. no-store is
193
247
  // the safe default until a caller opts into caching.
248
+ //
249
+ // This one DOES swallow control-plane unreachability — the
250
+ // providers list is decorative chrome on the /login page and a
251
+ // transient hiccup shouldn't make the whole login surface
252
+ // error-boundary. The form still works (server-side auth
253
+ // endpoints handle their own errors); the user just sees no
254
+ // "Sign in with Google" button for a moment.
194
255
  try {
195
- const res = await fetch(`${target()}/api/auth/providers`, {
196
- cache: "no-store",
197
- });
256
+ const res = await fetchWithTimeout(`${target()}/api/auth/providers`);
198
257
  if (!res.ok) return [];
199
258
  return (await res.json()) as OAuthProvider[];
200
259
  } catch {
@@ -211,24 +270,25 @@ export function createPylonServer(config: PylonServerConfig): PylonServer {
211
270
  // to safe fields (passwordHash + framework-internal columns
212
271
  // stripped). This replaces the older two-step
213
272
  // /api/auth/me + /api/fn/getMe dance.
273
+ //
274
+ // Network failures throw PylonUnreachableError (caught by the
275
+ // route's error.tsx). Older code swallowed them as null which
276
+ // made `requireMe` redirect to /login during a backend restart
277
+ // even though the user's session was perfectly valid — a
278
+ // "signed out" UX for what was actually a 5-second hiccup.
214
279
  const session = await readSession();
215
280
  if (!session) return null;
216
- const resp = await fetch(`${target()}/api/auth/session`, {
281
+ const res = await fetchWithTimeout(`${target()}/api/auth/session`, {
217
282
  headers: { cookie: session.header },
218
- cache: "no-store",
219
- })
220
- .then(
221
- (r) =>
222
- r.json() as Promise<{
223
- session?: {
224
- user_id?: string;
225
- tenant_id?: string | null;
226
- is_admin?: boolean;
227
- };
228
- user?: U | null;
229
- }>,
230
- )
231
- .catch(() => ({}) as { session?: undefined; user?: undefined });
283
+ });
284
+ const resp = (await res.json()) as {
285
+ session?: {
286
+ user_id?: string;
287
+ tenant_id?: string | null;
288
+ is_admin?: boolean;
289
+ };
290
+ user?: U | null;
291
+ };
232
292
  if (!resp.session?.user_id || !resp.user) return null;
233
293
  return {
234
294
  auth: {
@@ -346,15 +406,21 @@ export async function pylonJson<T = unknown>(
346
406
  */
347
407
  export async function getOAuthProviders(opts: {
348
408
  target?: string;
409
+ timeoutMs?: number;
349
410
  } = {}): Promise<OAuthProvider[]> {
350
411
  const target = resolveTarget(opts.target);
412
+ const ac = new AbortController();
413
+ const t = setTimeout(() => ac.abort(), opts.timeoutMs ?? 10_000);
351
414
  try {
352
415
  const res = await fetch(`${target}/api/auth/providers`, {
353
416
  cache: "no-store",
417
+ signal: ac.signal,
354
418
  });
355
419
  if (!res.ok) return [];
356
420
  return (await res.json()) as OAuthProvider[];
357
421
  } catch {
358
422
  return [];
423
+ } finally {
424
+ clearTimeout(t);
359
425
  }
360
426
  }