convex 1.42.0 → 1.42.1

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 (46) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/browser.bundle.js +24 -6
  3. package/dist/browser.bundle.js.map +2 -2
  4. package/dist/cjs/browser/sync/authentication_manager.js +21 -4
  5. package/dist/cjs/browser/sync/authentication_manager.js.map +2 -2
  6. package/dist/cjs/browser/sync/client.js +2 -1
  7. package/dist/cjs/browser/sync/client.js.map +2 -2
  8. package/dist/cjs/cli/lib/typecheck.js +28 -10
  9. package/dist/cjs/cli/lib/typecheck.js.map +2 -2
  10. package/dist/cjs/index.js +1 -1
  11. package/dist/cjs/index.js.map +1 -1
  12. package/dist/cjs/server/impl/registration_impl.js +0 -2
  13. package/dist/cjs/server/impl/registration_impl.js.map +2 -2
  14. package/dist/cjs-types/browser/sync/authentication_manager.d.ts +2 -0
  15. package/dist/cjs-types/browser/sync/authentication_manager.d.ts.map +1 -1
  16. package/dist/cjs-types/browser/sync/client.d.ts +16 -0
  17. package/dist/cjs-types/browser/sync/client.d.ts.map +1 -1
  18. package/dist/cjs-types/index.d.ts +1 -1
  19. package/dist/cjs-types/server/impl/registration_impl.d.ts.map +1 -1
  20. package/dist/cli.bundle.cjs +53 -16
  21. package/dist/cli.bundle.cjs.map +3 -3
  22. package/dist/esm/browser/sync/authentication_manager.js +21 -4
  23. package/dist/esm/browser/sync/authentication_manager.js.map +2 -2
  24. package/dist/esm/browser/sync/client.js +2 -1
  25. package/dist/esm/browser/sync/client.js.map +2 -2
  26. package/dist/esm/cli/lib/typecheck.js +28 -10
  27. package/dist/esm/cli/lib/typecheck.js.map +3 -3
  28. package/dist/esm/index.js +1 -1
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/server/impl/registration_impl.js +0 -2
  31. package/dist/esm/server/impl/registration_impl.js.map +2 -2
  32. package/dist/esm-types/browser/sync/authentication_manager.d.ts +2 -0
  33. package/dist/esm-types/browser/sync/authentication_manager.d.ts.map +1 -1
  34. package/dist/esm-types/browser/sync/client.d.ts +16 -0
  35. package/dist/esm-types/browser/sync/client.d.ts.map +1 -1
  36. package/dist/esm-types/index.d.ts +1 -1
  37. package/dist/esm-types/server/impl/registration_impl.d.ts.map +1 -1
  38. package/dist/react.bundle.js +24 -6
  39. package/dist/react.bundle.js.map +2 -2
  40. package/package.json +1 -1
  41. package/src/browser/sync/authentication_manager.ts +42 -14
  42. package/src/browser/sync/client.ts +17 -0
  43. package/src/cli/lib/typecheck.ts +26 -10
  44. package/src/index.ts +1 -1
  45. package/src/react/ConvexAuthState.test.tsx +145 -0
  46. package/src/server/impl/registration_impl.ts +0 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "convex",
3
3
  "description": "Client for the Convex Cloud",
4
- "version": "1.42.0",
4
+ "version": "1.42.1",
5
5
  "author": "Convex, Inc. <no-reply@convex.dev>",
6
6
  "homepage": "https://convex.dev",
7
7
  "repository": {
@@ -36,16 +36,19 @@ type AuthConfig = {
36
36
  };
37
37
 
38
38
  /**
39
- * In general we take 3 steps:
40
- * 1. Fetch a possibly cached token
39
+ * By default we take 3 steps:
40
+ * 1. Fetch a possibly cached token and send it to the server
41
41
  * 2. Immediately fetch a fresh token without using a cache
42
42
  * 3. Repeat step 2 before the end of the fresh token's lifetime
43
43
  *
44
- * When we fetch without using a cache we know when the token
45
- * will expire, and can schedule refetching it.
44
+ * When `initialAuthTokenReuse` is enabled, step 2 is skipped: the
45
+ * cached token is reused and a refetch is scheduled based on its
46
+ * remaining lifetime (estimated via the server's clock skew). This
47
+ * avoids a second Authenticate message that would cause the server
48
+ * to re-execute all authenticated queries.
46
49
  *
47
- * If we get an error before a scheduled refetch, we go back
48
- * to step 2.
50
+ * If the server rejects a token before a scheduled refetch, we
51
+ * immediately fetch a fresh token and retry.
49
52
  */
50
53
  type AuthState =
51
54
  | { state: "noAuth" }
@@ -97,6 +100,7 @@ export class AuthenticationManager {
97
100
  private readonly clearAuth: () => void;
98
101
  private readonly logger: Logger;
99
102
  private readonly refreshTokenLeewaySeconds: number;
103
+ private readonly initialAuthTokenReuse: boolean;
100
104
  // Track last value to avoid redundant calls
101
105
  private lastRefreshChange: boolean;
102
106
  // Number of times we have attempted to confirm the latest token. We retry up
@@ -115,6 +119,7 @@ export class AuthenticationManager {
115
119
  config: {
116
120
  refreshTokenLeewaySeconds: number;
117
121
  logger: Logger;
122
+ initialAuthTokenReuse: boolean;
118
123
  },
119
124
  ) {
120
125
  this.syncState = syncState;
@@ -126,6 +131,7 @@ export class AuthenticationManager {
126
131
  this.clearAuth = callbacks.clearAuth;
127
132
  this.logger = config.logger;
128
133
  this.refreshTokenLeewaySeconds = config.refreshTokenLeewaySeconds;
134
+ this.initialAuthTokenReuse = config.initialAuthTokenReuse;
129
135
  this.lastRefreshChange = false;
130
136
  }
131
137
 
@@ -206,7 +212,12 @@ export class AuthenticationManager {
206
212
 
207
213
  if (this.authState.state === "waitingForServerConfirmationOfCachedToken") {
208
214
  this._logVerbose("server confirmed auth token is valid");
209
- void this.refetchToken();
215
+ const cachedToken = this.syncState.getAuth()?.value;
216
+ if (this.initialAuthTokenReuse && cachedToken) {
217
+ this.scheduleTokenRefetch(cachedToken, serverMessage.clientClockSkew);
218
+ } else {
219
+ void this.refetchToken();
220
+ }
210
221
  this.authState.config.onAuthChange(true);
211
222
  return;
212
223
  }
@@ -361,7 +372,7 @@ export class AuthenticationManager {
361
372
  this.tryRestartSocket();
362
373
  }
363
374
 
364
- private scheduleTokenRefetch(token: string) {
375
+ private scheduleTokenRefetch(token: string, clientClockSkewMs?: number) {
365
376
  if (this.authState.state === "noAuth") {
366
377
  return;
367
378
  }
@@ -384,17 +395,34 @@ export class AuthenticationManager {
384
395
  );
385
396
  return;
386
397
  }
387
- // Because the client and server clocks may be out of sync,
388
- // we only know that the token will expire after `exp - iat`,
389
- // and since we just fetched a fresh one we know when that
390
- // will happen.
391
- const tokenValiditySeconds = exp - iat;
392
- if (tokenValiditySeconds <= 2) {
398
+ const fullLifetimeSeconds = exp - iat;
399
+ if (fullLifetimeSeconds <= 2) {
393
400
  this.logger.error(
394
401
  "Auth token does not live long enough, cannot refetch the token",
395
402
  );
396
403
  return;
397
404
  }
405
+ let tokenValiditySeconds: number;
406
+ if (clientClockSkewMs !== undefined) {
407
+ // For cached tokens we use the server's clock skew estimate to
408
+ // compute remaining lifetime. The server computes
409
+ // clientClockSkew = clientTs - serverReceiveTs, so it's positive
410
+ // when the client is ahead and negative when behind. To recover
411
+ // server time: serverNow = Date.now() - clientClockSkew.
412
+ // Both Date.now() and setTimeout use the same client clock, so
413
+ // the scheduled delay is accurate regardless of absolute offset.
414
+ // Connect latency inflates the skew toward "client behind," which
415
+ // is conservative — we schedule the refetch slightly early.
416
+ const estimatedServerNowSeconds = (Date.now() - clientClockSkewMs) / 1000;
417
+ tokenValiditySeconds = exp - estimatedServerNowSeconds;
418
+ if (tokenValiditySeconds <= 0) {
419
+ tokenValiditySeconds = 0;
420
+ }
421
+ } else {
422
+ // For freshly-fetched tokens, `exp - iat` is the full lifetime
423
+ // starting from now.
424
+ tokenValiditySeconds = fullLifetimeSeconds;
425
+ }
398
426
  // Attempt to refresh the token `refreshTokenLeewaySeconds` before it expires,
399
427
  // or immediately if the token is already expiring soon.
400
428
  let delay = Math.min(
@@ -137,6 +137,22 @@ export interface BaseConvexClientOptions {
137
137
  * Defaults to false, not waiting for an auth token.
138
138
  */
139
139
  expectAuth?: boolean;
140
+ /**
141
+ * This API is experimental: it may change or disappear.
142
+ *
143
+ * When true, the client reuses the initial cached auth token instead
144
+ * of immediately fetching a fresh one. This avoids a second
145
+ * Authenticate message that causes the server to re-execute all
146
+ * authenticated queries.
147
+ *
148
+ * The cached token's remaining lifetime is estimated using the
149
+ * server's clock skew measurement, and a refresh is scheduled
150
+ * before it expires.
151
+ *
152
+ * Defaults to false, preserving the original behavior of immediately
153
+ * fetching a fresh token after the cached token is confirmed.
154
+ */
155
+ initialAuthTokenReuse?: boolean;
140
156
  }
141
157
 
142
158
  /**
@@ -358,6 +374,7 @@ export class BaseConvexClient {
358
374
  {
359
375
  logger: this.logger,
360
376
  refreshTokenLeewaySeconds: authRefreshTokenLeewaySeconds,
377
+ initialAuthTokenReuse: options.initialAuthTokenReuse ?? false,
361
378
  },
362
379
  );
363
380
  this.optimisticQueryResults = new OptimisticQueryResults();
@@ -139,18 +139,34 @@ async function runTsc(
139
139
  tscArgs: string[],
140
140
  handleResult: TypecheckResultHandler,
141
141
  ): Promise<void> {
142
- // Check if tsc is even installed
143
- const tscPath =
144
- typescriptCompiler === "tsgo"
145
- ? path.join(
142
+ // Check if the TypeScript compiler is even installed
143
+ let compilerPath: string | undefined;
144
+ switch (typescriptCompiler) {
145
+ case "tsgo": {
146
+ // @typescript/native-preview@7.0.0-dev.20260626.1 switched to using `bin/tsgo` instead of `bin/tsgo.js`.
147
+ const tsgoPaths = ["tsgo", "tsgo.js"].map((filename) =>
148
+ path.join(
146
149
  "node_modules",
147
150
  "@typescript",
148
151
  "native-preview",
149
152
  "bin",
150
- "tsgo.js",
151
- )
152
- : path.join("node_modules", "typescript", "bin", "tsc");
153
- if (!ctx.fs.exists(tscPath)) {
153
+ filename,
154
+ ),
155
+ );
156
+ compilerPath = tsgoPaths.find((path) => ctx.fs.exists(path));
157
+ break;
158
+ }
159
+ case "tsc": {
160
+ const tscPath = path.join("node_modules", "typescript", "bin", "tsc");
161
+ if (ctx.fs.exists(tscPath)) {
162
+ compilerPath = tscPath;
163
+ }
164
+ break;
165
+ }
166
+ default:
167
+ typescriptCompiler satisfies never;
168
+ }
169
+ if (!compilerPath) {
154
170
  return handleResult("cantTypeCheck", () => {
155
171
  logError(
156
172
  chalkStderr.gray(
@@ -162,14 +178,14 @@ async function runTsc(
162
178
 
163
179
  // Check the TypeScript version matches the recommendation from Convex
164
180
  const versionResult = await spawnAsync(ctx, process.execPath, [
165
- tscPath,
181
+ compilerPath,
166
182
  "--version",
167
183
  ]);
168
184
 
169
185
  const version = versionResult.stdout.match(/Version (.*)/)?.[1] ?? null;
170
186
  const hasOlderTypeScriptVersion = version && semver.lt(version, "4.8.4");
171
187
 
172
- await runTscInner(ctx, tscPath, tscArgs, handleResult);
188
+ await runTscInner(ctx, compilerPath, tscArgs, handleResult);
173
189
 
174
190
  // Print this warning after any logs from running `tsc`
175
191
  if (hasOlderTypeScriptVersion) {
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export const version = "1.42.0";
1
+ export const version = "1.42.1";
@@ -198,6 +198,7 @@ test("Tokens are used to schedule refetch", async () => {
198
198
  function mockServerConfirmsAuth(
199
199
  client: ConvexReactClient,
200
200
  oldIdentityVersion: number,
201
+ clientClockSkew?: number,
201
202
  ) {
202
203
  act(() => {
203
204
  const querySetVersion = client.sync["remoteQuerySet"]["version"];
@@ -212,6 +213,7 @@ function mockServerConfirmsAuth(
212
213
  identity: oldIdentityVersion + 1,
213
214
  },
214
215
  modifications: [],
216
+ ...(clientClockSkew !== undefined ? { clientClockSkew } : {}),
215
217
  });
216
218
  });
217
219
  }
@@ -539,3 +541,146 @@ test("stop() during reauth notifies isRefreshing false", async () => {
539
541
 
540
542
  expect(onRefreshChange).toHaveBeenCalledWith(false);
541
543
  });
544
+
545
+ // --- initialAuthTokenReuse tests ---
546
+ // When enabled, the cached token is reused after server confirmation
547
+ // (no immediate fresh-token fetch), and the refetch is scheduled using
548
+ // the server's clientClockSkew to estimate remaining lifetime.
549
+ // The server computes clientClockSkew = clientTs - serverReceiveTs:
550
+ // negative means client behind, positive means ahead. Connect latency
551
+ // makes the skew more negative (conservative).
552
+
553
+ test("initialAuthTokenReuse: sends only one Authenticate", async () => {
554
+ const client = new ConvexReactClient("https://127.0.0.1:3001", {
555
+ initialAuthTokenReuse: true,
556
+ });
557
+ const tokenLifetimeSeconds = 60;
558
+ let tokenId = 0;
559
+ const tokenFetcher = vi.fn(async () =>
560
+ jwtEncode(
561
+ { iat: 1234500, exp: 1234500 + tokenLifetimeSeconds },
562
+ "secret" + tokenId++,
563
+ ),
564
+ );
565
+
566
+ const sendMessageSpy = vi.spyOn(
567
+ client.sync["webSocketManager"],
568
+ "sendMessage",
569
+ );
570
+
571
+ const onAuthChange = vi.fn();
572
+ void client.setAuth(tokenFetcher, onAuthChange);
573
+
574
+ await flushPromises();
575
+ mockServerConfirmsAuth(client, 0);
576
+ await flushPromises();
577
+
578
+ expect(onAuthChange).toHaveBeenCalledWith(true);
579
+
580
+ const authenticateMessages = sendMessageSpy.mock.calls.filter(
581
+ ([msg]) => msg.type === "Authenticate",
582
+ );
583
+ expect(authenticateMessages).toHaveLength(1);
584
+
585
+ await client.close();
586
+ });
587
+
588
+ test("initialAuthTokenReuse: clock skew adjusts schedule for partially-expired token", async () => {
589
+ const client = new ConvexReactClient("https://127.0.0.1:3001", {
590
+ initialAuthTokenReuse: true,
591
+ });
592
+ const defaultLeewaySeconds = 10;
593
+ const tokenLifetimeSeconds = 60;
594
+ const tokenAgeOnServer = 30;
595
+ const clientClockSkewMs = 0;
596
+ const clientNow = Date.now() / 1000;
597
+ const serverNow = clientNow;
598
+ const tokenFetcher = vi.fn(async () =>
599
+ jwtEncode(
600
+ {
601
+ iat: serverNow - tokenAgeOnServer,
602
+ exp: serverNow - tokenAgeOnServer + tokenLifetimeSeconds,
603
+ },
604
+ "secret",
605
+ ),
606
+ );
607
+ void client.setAuth(tokenFetcher, () => {});
608
+
609
+ await flushPromises();
610
+ mockServerConfirmsAuth(client, 0, clientClockSkewMs);
611
+
612
+ expect(tokenFetcher).toHaveBeenCalledTimes(1);
613
+
614
+ const expectedDelay =
615
+ (tokenLifetimeSeconds - tokenAgeOnServer - defaultLeewaySeconds) * 1000;
616
+
617
+ vi.advanceTimersByTime(expectedDelay - 1);
618
+ expect(tokenFetcher).toHaveBeenCalledTimes(1);
619
+
620
+ vi.advanceTimersByTime(1);
621
+ expect(tokenFetcher).toHaveBeenCalledTimes(2);
622
+ });
623
+
624
+ test("initialAuthTokenReuse: clock skew triggers immediate refetch for nearly-expired token", async () => {
625
+ const client = new ConvexReactClient("https://127.0.0.1:3001", {
626
+ initialAuthTokenReuse: true,
627
+ });
628
+ const tokenLifetimeSeconds = 60;
629
+ const clientClockSkewMs = -50_000;
630
+ const clientNow = Date.now() / 1000;
631
+ const serverNow = clientNow - clientClockSkewMs / 1000;
632
+ const tokenFetcher = vi.fn(async () =>
633
+ jwtEncode(
634
+ {
635
+ iat: serverNow - 55,
636
+ exp: serverNow - 55 + tokenLifetimeSeconds,
637
+ },
638
+ "secret",
639
+ ),
640
+ );
641
+ void client.setAuth(tokenFetcher, () => {});
642
+
643
+ await flushPromises();
644
+ mockServerConfirmsAuth(client, 0, clientClockSkewMs);
645
+
646
+ expect(tokenFetcher).toHaveBeenCalledTimes(1);
647
+
648
+ vi.advanceTimersByTime(1);
649
+ expect(tokenFetcher).toHaveBeenCalledTimes(2);
650
+ });
651
+
652
+ test("initialAuthTokenReuse: clock skew corrects for client clock behind server", async () => {
653
+ const client = new ConvexReactClient("https://127.0.0.1:3001", {
654
+ initialAuthTokenReuse: true,
655
+ });
656
+ const defaultLeewaySeconds = 10;
657
+ const tokenLifetimeSeconds = 60;
658
+ const tokenAgeOnServer = 30;
659
+ const clientClockSkewMs = -20_000;
660
+ const clientNow = Date.now() / 1000;
661
+ const serverNow = clientNow - clientClockSkewMs / 1000;
662
+ const tokenFetcher = vi.fn(async () =>
663
+ jwtEncode(
664
+ {
665
+ iat: serverNow - tokenAgeOnServer,
666
+ exp: serverNow - tokenAgeOnServer + tokenLifetimeSeconds,
667
+ },
668
+ "secret",
669
+ ),
670
+ );
671
+ void client.setAuth(tokenFetcher, () => {});
672
+
673
+ await flushPromises();
674
+ mockServerConfirmsAuth(client, 0, clientClockSkewMs);
675
+
676
+ expect(tokenFetcher).toHaveBeenCalledTimes(1);
677
+
678
+ const expectedDelay =
679
+ (tokenLifetimeSeconds - tokenAgeOnServer - defaultLeewaySeconds) * 1000;
680
+
681
+ vi.advanceTimersByTime(expectedDelay - 1);
682
+ expect(tokenFetcher).toHaveBeenCalledTimes(1);
683
+
684
+ vi.advanceTimersByTime(1);
685
+ expect(tokenFetcher).toHaveBeenCalledTimes(2);
686
+ });
@@ -500,7 +500,6 @@ export const internalQueryGeneric: QueryBuilder<any, "internal"> = ((
500
500
  async function invokeAction<
501
501
  F extends (ctx: GenericActionCtx<GenericDataModel>, ...args: any) => any,
502
502
  >(func: F, requestId: string, argsStr: string, visibility: FunctionVisibility) {
503
- (globalThis as any).Convex?.setupPerformance?.();
504
503
  const args = jsonToConvex(JSON.parse(argsStr));
505
504
  const calls = setupActionCalls(requestId);
506
505
  const ctx = {
@@ -682,7 +681,6 @@ export const internalActionGeneric: ActionBuilder<any, "internal"> = ((
682
681
  async function invokeHttpAction<
683
682
  F extends (ctx: GenericActionCtx<GenericDataModel>, request: Request) => any,
684
683
  >(func: F, request: Request) {
685
- (globalThis as any).Convex?.setupPerformance?.();
686
684
  // TODO(presley): Change the function signature and propagate the requestId from Rust.
687
685
  // Ok, to mock it out for now, since http endpoints are only running in V8.
688
686
  const requestId = "";