Skip to content

Commit 427785c

Browse files
committed
frontend/client/query: refactor and only add timeout for registration token
1 parent e64e1a6 commit 427785c

File tree

6 files changed

+195
-101
lines changed

6 files changed

+195
-101
lines changed

src/AGENTS.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,24 @@ This file provides guidance to Claude Code (claude.ai/code) and also Gemini CLI
4242
- `cd packages/[package] && pnpm build` - Build and compile a specific package
4343
- For packages/next and packages/static, run `cd packages/[package] && pnpm build-dev`
4444
- `cd packages/[package] && pnpm test` - Run tests for a specific package
45-
- To typecheck the frontend, it is best to run `cd packages/static && pnpm build` - this implicitly compiles the frontend and reports TypeScript errors
4645
- **IMPORTANT**: When modifying packages like `util` that other packages depend on, you must run `pnpm build` in the modified package before typechecking dependent packages
4746

4847
### Development
4948

5049
- **IMPORTANT**: Always run `prettier -w [filename]` immediately after editing any .ts, .tsx, .md, or .json file to ensure consistent styling
51-
- After TypeScript or `*.tsx` changes, run `pnpm build` in the relevant package directory
52-
- When editing the frontend, run `pnpm build-dev` in `packages/static` (this implicitly builds the frontend)
50+
51+
#### When Working on Frontend Code
52+
53+
After making changes to files in `packages/frontend`:
54+
55+
1. **Typecheck**: Run `cd packages/frontend && pnpm tsc --noEmit` to check for TypeScript errors
56+
2. **Build**: Run `cd packages/static && pnpm build-dev` to compile the frontend for testing
57+
58+
**DO NOT** run `pnpm build` in `packages/frontend` - it won't work as expected for frontend development.
59+
60+
#### When Working on Other Packages
61+
62+
- After TypeScript changes, run `pnpm build` in the relevant package directory
5363

5464
## Architecture Overview
5565

@@ -155,11 +165,11 @@ CoCalc is organized as a monorepo with key packages:
155165

156166
### Development Workflow
157167

158-
1. Changes to TypeScript require compilation (`pnpm build` in relevant package)
159-
2. Database must be running before starting hub
160-
3. Hub coordinates all services and should be restarted after changes
161-
4. Use `pnpm clean && pnpm build-dev` when switching branches or after major changes
162-
5. **IMPORTANT**: After any frontend code changes, run `pnpm build-dev` in the `packages/static` directory to compile the frontend
168+
1. **Frontend changes**: After editing `packages/frontend`, typecheck with `cd packages/frontend && pnpm tsc --noEmit`, then build with `cd packages/static && pnpm build-dev`
169+
2. **Other package changes**: After TypeScript changes, run `pnpm build` in the relevant package directory
170+
3. Database must be running before starting hub
171+
4. Hub coordinates all services and should be restarted after changes
172+
5. Use `pnpm clean && pnpm build-dev` when switching branches or after major changes
163173

164174
# Workflow
165175

src/packages/frontend/admin/registration-token-hook.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,6 @@ export function formatEphemeralHours(value?: number): string {
2929
return seconds2hms(seconds, false, false, false);
3030
}
3131

32-
export function ephemeralSignupUrl(token?: string): string {
33-
if (!token) return "";
34-
if (typeof window === "undefined") {
35-
return `/ephemeral?token=${token}`;
36-
}
37-
const { protocol, host } = window.location;
38-
return `${protocol}//${host}/ephemeral?token=${token}`;
39-
}
40-
4132
export function getEphemeralMode(ephemeral?: number): string | undefined {
4233
const presetKey = findPresetKey(ephemeral);
4334
if (presetKey) return presetKey;
@@ -155,6 +146,7 @@ export function useRegistrationTokens() {
155146
query: {
156147
registration_tokens: val,
157148
},
149+
timeout: 15000,
158150
});
159151
// we save the original one, with dayjs in it!
160152
setLastSaved(val_orig);
@@ -204,8 +196,8 @@ export function useRegistrationTokens() {
204196
async function deleteTokens(): Promise<void> {
205197
setDeleting(true);
206198
try {
207-
// it's not possible to delete several tokens at once
208-
await selRows.map(async (token) => await deleteToken(token));
199+
// Delete tokens in parallel and wait for all to complete
200+
await Promise.all(selRows.map((token) => deleteToken(token)));
209201
setSelRows([]);
210202
load();
211203
} catch (err) {

src/packages/frontend/admin/registration-token.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Descriptions,
1313
Popconfirm,
1414
Progress,
15+
Space,
1516
Switch,
1617
Table,
1718
} from "antd";
@@ -44,7 +45,6 @@ import { PassportStrategyFrontend } from "@cocalc/util/types/passport-types";
4445

4546
import RegistrationTokenDialog from "./registration-token-dialog";
4647
import {
47-
ephemeralSignupUrl,
4848
formatEphemeralHours,
4949
useRegistrationTokens,
5050
} from "./registration-token-hook";
@@ -83,7 +83,7 @@ export function RegistrationToken() {
8383
function render_buttons() {
8484
const any_selected = selRows.length > 0;
8585
return (
86-
<AntdButton.Group style={{ margin: "10px 0" }}>
86+
<Space.Compact style={{ margin: "10px 0" }}>
8787
<AntdButton
8888
type={!any_selected ? "primary" : "default"}
8989
disabled={any_selected}
@@ -107,10 +107,19 @@ export function RegistrationToken() {
107107
<Icon name="refresh" />
108108
Refresh
109109
</AntdButton>
110-
</AntdButton.Group>
110+
</Space.Compact>
111111
);
112112
}
113113

114+
function ephemeralSignupUrl(token: Token): string {
115+
if (!token || token.ephemeral == null) return "";
116+
if (typeof window === "undefined") {
117+
return `/ephemeral?token=${token.token}`;
118+
}
119+
const { protocol, host } = window.location;
120+
return `${protocol}//${host}/ephemeral?token=${token.token}`;
121+
}
122+
114123
function render_expanded_row(token: Token): Rendered {
115124
const uses = token.counter ?? 0;
116125
const limit = token.limit;
@@ -128,7 +137,7 @@ export function RegistrationToken() {
128137
token.ephemeral != null
129138
? seconds2hms(token.ephemeral / 1000, true)
130139
: "No";
131-
const ephemeralLink = ephemeralSignupUrl(token.token);
140+
const ephemeralLink = ephemeralSignupUrl(token);
132141

133142
const items: DescriptionsProps["items"] = [
134143
{
@@ -263,7 +272,7 @@ export function RegistrationToken() {
263272
dataIndex="ephemeral"
264273
render={(value, token) => {
265274
if (value == null) return "-";
266-
const url = ephemeralSignupUrl(token.token);
275+
const url = ephemeralSignupUrl(token);
267276
return (
268277
<div
269278
style={{

src/packages/frontend/client/query.ts

Lines changed: 112 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,79 @@ export class QueryClient {
1919
this.client = client;
2020
}
2121

22+
private doChangefeed = async (opts: {
23+
query: object;
24+
options?: object[];
25+
cb: CB;
26+
}): Promise<void> => {
27+
let changefeed;
28+
try {
29+
changefeed = new ConatChangefeed({
30+
account_id: this.client.account_id,
31+
query: opts.query,
32+
options: opts.options,
33+
});
34+
// id for canceling this changefeed
35+
const id = uuid();
36+
const initval = await changefeed.connect();
37+
const query = {
38+
[Object.keys(opts.query)[0]]: initval,
39+
};
40+
this.changefeeds[id] = changefeed;
41+
opts.cb(undefined, { query, id });
42+
changefeed.on("update", (change) => {
43+
opts.cb(undefined, change);
44+
});
45+
} catch (err) {
46+
opts.cb(`${err}`);
47+
}
48+
};
49+
50+
private doQuery = async (opts: {
51+
query: object;
52+
options?: object[];
53+
timeout?: number;
54+
}): Promise<any> => {
55+
let timer: ReturnType<typeof setTimeout> | undefined;
56+
57+
try {
58+
const queryPromise = this.client.conat_client.hub.db.userQuery({
59+
query: opts.query,
60+
options: opts.options,
61+
timeout: opts.timeout,
62+
});
63+
64+
// Add client-side timeout if explicitly requested
65+
if (opts.timeout != null) {
66+
let timedOut = false;
67+
68+
const timeoutPromise = new Promise<never>((_, reject) => {
69+
timer = setTimeout(() => {
70+
timedOut = true; // Set flag before rejecting
71+
reject(new Error(`Query timed out after ${opts.timeout} ms`));
72+
}, opts.timeout);
73+
});
74+
75+
// Prevent unhandled rejection if timeout fires first
76+
queryPromise.catch((err) => {
77+
if (timedOut) {
78+
// Timeout already happened, this is an orphaned rejection - just log it
79+
console.warn("Query failed after client-side timeout:", err);
80+
}
81+
// If not timed out, error is handled by the race
82+
});
83+
84+
return await Promise.race([queryPromise, timeoutPromise]);
85+
} else {
86+
return await queryPromise;
87+
}
88+
} finally {
89+
if (timer != null) {
90+
clearTimeout(timer);
91+
}
92+
}
93+
};
94+
2295
// This works like a normal async function when
2396
// opts.cb is NOT specified. When opts.cb is specified,
2497
// it works like a cb and returns nothing. For changefeeds
@@ -30,84 +103,50 @@ export class QueryClient {
30103
timeout?: number; // ms
31104
cb?: CB; // support old cb interface
32105
}): Promise<any> => {
33-
const timeoutMs = opts.timeout ?? 15000;
34-
let timer: ReturnType<typeof setTimeout> | undefined;
35-
const timeoutPromise = new Promise<never>((_, reject) => {
36-
timer = setTimeout(
37-
() => reject(new Error(`Query timed out after ${timeoutMs} ms`)),
38-
timeoutMs,
39-
);
40-
});
41-
42-
try {
43-
// Deprecation warnings:
44-
for (const field of ["standby", "no_post", "ignore_response"]) {
45-
if (opts[field] != null) {
46-
console.trace(`WARNING: passing '${field}' to query is deprecated`);
47-
}
106+
// Deprecation warnings:
107+
for (const field of ["standby", "no_post", "ignore_response"]) {
108+
if (opts[field] != null) {
109+
console.trace(`WARNING: passing '${field}' to query is deprecated`);
48110
}
49-
if (opts.options != null && !is_array(opts.options)) {
50-
// should never happen...
51-
throw Error("options must be an array");
111+
}
112+
if (opts.options != null && !is_array(opts.options)) {
113+
// should never happen...
114+
throw Error("options must be an array");
115+
}
116+
if (opts.changes) {
117+
const { cb } = opts;
118+
if (cb == null) {
119+
throw Error("for changefeed, must specify opts.cb");
52120
}
53-
if (opts.changes) {
54-
const { cb } = opts;
55-
if (cb == null) {
56-
throw Error("for changefeed, must specify opts.cb");
57-
}
58-
let changefeed;
59-
try {
60-
changefeed = new ConatChangefeed({
61-
account_id: this.client.account_id,
62-
query: opts.query,
63-
options: opts.options,
64-
});
65-
// id for canceling this changefeed
66-
const id = uuid();
67-
const initval = await changefeed.connect();
68-
const query = {
69-
[Object.keys(opts.query)[0]]: initval,
70-
};
71-
this.changefeeds[id] = changefeed;
72-
cb(undefined, { query, id });
73-
changefeed.on("update", (change) => {
74-
cb(undefined, change);
75-
});
76-
} catch (err) {
77-
cb(`${err}`);
78-
return;
121+
await this.doChangefeed({
122+
query: opts.query,
123+
options: opts.options,
124+
cb,
125+
});
126+
} else {
127+
try {
128+
const err = validate_client_query(opts.query, this.client.account_id);
129+
if (err) {
130+
throw Error(err);
79131
}
80-
} else {
81-
try {
82-
const err = validate_client_query(opts.query, this.client.account_id);
83-
if (err) {
84-
throw Error(err);
85-
}
86-
const query = await Promise.race([
87-
this.client.conat_client.hub.db.userQuery({
88-
query: opts.query,
89-
options: opts.options,
90-
timeout: opts.timeout,
91-
}),
92-
timeoutPromise,
93-
]);
94132

95-
if (opts.cb == null) {
96-
return { query };
97-
} else {
98-
opts.cb(undefined, { query });
99-
}
100-
} catch (err) {
101-
if (opts.cb == null) {
102-
throw err;
103-
} else {
104-
opts.cb(err);
105-
}
133+
const query = await this.doQuery({
134+
query: opts.query,
135+
options: opts.options,
136+
timeout: opts.timeout,
137+
});
138+
139+
if (opts.cb == null) {
140+
return { query };
141+
} else {
142+
opts.cb(undefined, { query });
143+
}
144+
} catch (err) {
145+
if (opts.cb == null) {
146+
throw err;
147+
} else {
148+
opts.cb(err);
106149
}
107-
}
108-
} finally {
109-
if (timer != null) {
110-
clearTimeout(timer);
111150
}
112151
}
113152
};

src/packages/frontend/frame-editors/generic/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ interface QueryOpts {
258258
changes?: boolean;
259259
options?: object[]; // e.g., [{limit:5}],
260260
no_post?: boolean;
261+
timeout?: number; // ms
261262
}
262263

263264
export async function query(opts: QueryOpts): Promise<any> {

0 commit comments

Comments
 (0)