@pylonsync/next 0.3.139 → 0.3.142
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/package.json +3 -3
- 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.
|
|
6
|
+
"version": "0.3.142",
|
|
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.
|
|
22
|
-
"@pylonsync/react": "0.3.
|
|
21
|
+
"@pylonsync/sdk": "0.3.142",
|
|
22
|
+
"@pylonsync/react": "0.3.142"
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
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
|
|
281
|
+
const res = await fetchWithTimeout(`${target()}/api/auth/session`, {
|
|
217
282
|
headers: { cookie: session.header },
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
}
|