@kainoa/5c-mcp 0.4.0
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 +33 -0
- package/dist/chunk-3PCAZ5XS.js +258 -0
- package/dist/chunk-B3TGUWJT.js +253 -0
- package/dist/chunk-R2DX67JM.js +257 -0
- package/dist/hyperschedule-32D52IZE-P6VRQDQR.js +21 -0
- package/dist/hyperschedule-OT6BLROL-BRSP2NPX.js +21 -0
- package/dist/hyperschedule-VZ6NIDDP-NJRKLKJI.js +21 -0
- package/dist/server.js +4968 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @kainoa/5c-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for the Claremont Colleges (5C) course catalog — 5 read-only tools
|
|
4
|
+
over the shared 5c-core engine: `search_classes` · `get_classes` ·
|
|
5
|
+
`check_schedule` · `list_terms` · `get_course_history`.
|
|
6
|
+
|
|
7
|
+
Every tool declares an input **and** an output schema, so the documented shape
|
|
8
|
+
is the validated shape. Input schemas are strict (`additionalProperties:
|
|
9
|
+
false`): unknown parameters are rejected with -32602 naming the key.
|
|
10
|
+
Logs go to stderr only; stdout carries MCP messages.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Point your MCP host (Claude Code, Codex, Cursor, Desktop) at npm — no clone, no
|
|
15
|
+
build:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"5c-index": { "command": "npx", "args": ["-y", "@kainoa/5c-mcp"] }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires Node 20 or newer. Experimental stateless Streamable HTTP on localhost:
|
|
26
|
+
`npx -y @kainoa/5c-mcp --http --port 3000`.
|
|
27
|
+
|
|
28
|
+
Data comes from [HyperSchedule](https://hyperschedule.io) (the 5C registrars'
|
|
29
|
+
catalogs) through a local snapshot cache; seats are snapshots, always quoted
|
|
30
|
+
with `fetched_at` and a `stale` flag.
|
|
31
|
+
|
|
32
|
+
The CLI twin ([`@kainoa/5c-cli`](https://www.npmjs.com/package/@kainoa/5c-cli)) and docs:
|
|
33
|
+
[notkainoa/5C-Index](https://github.com/notkainoa/5C-Index).
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../core/dist/chunk-M5LVN3BK.js
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
var rawDateSchema = z.object({
|
|
6
|
+
year: z.number(),
|
|
7
|
+
month: z.number().min(1).max(12),
|
|
8
|
+
day: z.number().min(1).max(31),
|
|
9
|
+
});
|
|
10
|
+
var rawCourseCodeSchema = z.object({
|
|
11
|
+
department: z.string(),
|
|
12
|
+
courseNumber: z.number(),
|
|
13
|
+
suffix: z.string(),
|
|
14
|
+
affiliation: z.string(),
|
|
15
|
+
});
|
|
16
|
+
var rawScheduleSchema = z.object({
|
|
17
|
+
startTime: z.number(),
|
|
18
|
+
// seconds since midnight
|
|
19
|
+
endTime: z.number(),
|
|
20
|
+
days: z.array(z.string()),
|
|
21
|
+
locations: z.array(z.string()),
|
|
22
|
+
});
|
|
23
|
+
var rawSectionSchema = z.object({
|
|
24
|
+
courseAreas: z.array(z.string()),
|
|
25
|
+
credits: z.number(),
|
|
26
|
+
permCount: z.number().default(0),
|
|
27
|
+
seatsTotal: z.number(),
|
|
28
|
+
seatsFilled: z.number(),
|
|
29
|
+
status: z.string(),
|
|
30
|
+
// O | C | R | U (keep raw; map to labels downstream)
|
|
31
|
+
startDate: rawDateSchema,
|
|
32
|
+
endDate: rawDateSchema,
|
|
33
|
+
instructors: z.array(z.object({ name: z.string() })),
|
|
34
|
+
course: z.object({
|
|
35
|
+
code: rawCourseCodeSchema,
|
|
36
|
+
title: z.string(),
|
|
37
|
+
description: z.string(),
|
|
38
|
+
primaryAssociation: z.string(),
|
|
39
|
+
potentialError: z.boolean().default(false),
|
|
40
|
+
}),
|
|
41
|
+
schedules: z.array(rawScheduleSchema),
|
|
42
|
+
potentialError: z.boolean().default(false),
|
|
43
|
+
identifier: z.object({
|
|
44
|
+
department: z.string(),
|
|
45
|
+
courseNumber: z.number(),
|
|
46
|
+
suffix: z.string(),
|
|
47
|
+
affiliation: z.string(),
|
|
48
|
+
sectionNumber: z.number(),
|
|
49
|
+
year: z.number(),
|
|
50
|
+
term: z.string(),
|
|
51
|
+
// FA | SP | SU
|
|
52
|
+
half: z.nullable(z.object({ prefix: z.string(), number: z.number() })),
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
var rawSectionsSchema = z.array(rawSectionSchema);
|
|
56
|
+
var rawHistoryEntrySchema = z.object({
|
|
57
|
+
code: rawCourseCodeSchema,
|
|
58
|
+
terms: z.array(z.object({ year: z.number(), term: z.string() })),
|
|
59
|
+
});
|
|
60
|
+
var rawHistorySchema = z.array(rawHistoryEntrySchema);
|
|
61
|
+
var rawTermSchema = z.object({ year: z.number(), term: z.string() });
|
|
62
|
+
var rawCourseAreaSchema = z.object({
|
|
63
|
+
area: z.string(),
|
|
64
|
+
description: z.string(),
|
|
65
|
+
});
|
|
66
|
+
var termSchema = z
|
|
67
|
+
.string()
|
|
68
|
+
.regex(/^(FA|SP|SU)\d{4}$/, "Term must look like FA2026");
|
|
69
|
+
var daySchema = z.enum(["M", "T", "W", "R", "F", "S", "U"]);
|
|
70
|
+
var statusSchema = z.enum(["O", "C", "R", "U"]);
|
|
71
|
+
var meetingSchema = z.object({
|
|
72
|
+
days: z.array(daySchema),
|
|
73
|
+
startMin: z.number(),
|
|
74
|
+
// minutes since midnight (math)
|
|
75
|
+
endMin: z.number(),
|
|
76
|
+
startStr: z.string(),
|
|
77
|
+
// "13:15" (quote)
|
|
78
|
+
endStr: z.string(),
|
|
79
|
+
start12: z.string(),
|
|
80
|
+
// "1:15 PM" (display)
|
|
81
|
+
end12: z.string(),
|
|
82
|
+
locations: z.array(z.string()),
|
|
83
|
+
});
|
|
84
|
+
var sectionSchema = z.object({
|
|
85
|
+
id: z.string(),
|
|
86
|
+
// "CSCI 051 PO-01 FA2026" (canonical, frozen v1)
|
|
87
|
+
term: termSchema,
|
|
88
|
+
year: z.number(),
|
|
89
|
+
termCode: z.enum(["FA", "SP", "SU"]),
|
|
90
|
+
half: z.nullable(z.object({ prefix: z.string(), number: z.number() })),
|
|
91
|
+
dept: z.string(),
|
|
92
|
+
number: z.number(),
|
|
93
|
+
suffix: z.string(),
|
|
94
|
+
school: z.string(),
|
|
95
|
+
// affiliation, uppercase (PO/CM/HM/SC/PZ + KS/JP/…)
|
|
96
|
+
sectionNo: z.number(),
|
|
97
|
+
code: z.string(),
|
|
98
|
+
// "CSCI 051" (dept + zero-padded + suffix, no school)
|
|
99
|
+
title: z.string(),
|
|
100
|
+
description: z.string(),
|
|
101
|
+
areas: z.array(z.string()),
|
|
102
|
+
instructors: z.array(z.string()),
|
|
103
|
+
meetings: z.array(meetingSchema),
|
|
104
|
+
credits: z.number(),
|
|
105
|
+
status: statusSchema,
|
|
106
|
+
statusLabel: z.string(),
|
|
107
|
+
// Open | Closed | Reopened | Unknown
|
|
108
|
+
seats: z.object({
|
|
109
|
+
filled: z.number(),
|
|
110
|
+
total: z.number(),
|
|
111
|
+
available: z.number(),
|
|
112
|
+
permCount: z.number(),
|
|
113
|
+
}),
|
|
114
|
+
startDate: z.string(),
|
|
115
|
+
// ISO
|
|
116
|
+
endDate: z.string(),
|
|
117
|
+
potentialError: z.boolean(),
|
|
118
|
+
primaryAssociation: z.string(),
|
|
119
|
+
affiliation: z.string(),
|
|
120
|
+
});
|
|
121
|
+
var cacheMetaSchema = z.object({
|
|
122
|
+
term: z.string(),
|
|
123
|
+
fetched_at: z.string(),
|
|
124
|
+
etag: z.string().nullable(),
|
|
125
|
+
hash: z.string(),
|
|
126
|
+
count: z.number(),
|
|
127
|
+
source_url: z.string(),
|
|
128
|
+
ttl_kind: z.string(),
|
|
129
|
+
stale: z.boolean(),
|
|
130
|
+
source: z.enum(["live", "stale-cache", "offline-file"]),
|
|
131
|
+
});
|
|
132
|
+
var BANANA_BASE = "https://banana.hyperschedule.io/v4";
|
|
133
|
+
var USER_AGENT = "5C-Index/0.1 (+research/edu; cache 60s)";
|
|
134
|
+
async function sleep(ms) {
|
|
135
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
136
|
+
}
|
|
137
|
+
async function fetchJson(url, validate, opts = {}, fetchFn = globalThis.fetch) {
|
|
138
|
+
const { etag, timeoutMs = 15e3, retries = 3 } = opts;
|
|
139
|
+
let lastErr = null;
|
|
140
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
141
|
+
const ctrl = new AbortController();
|
|
142
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetchFn(url, {
|
|
145
|
+
signal: ctrl.signal,
|
|
146
|
+
headers: {
|
|
147
|
+
Accept: "application/json",
|
|
148
|
+
"User-Agent": USER_AGENT,
|
|
149
|
+
...(etag ? { "If-None-Match": etag } : {}),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
if (res.status === 304) {
|
|
154
|
+
return { data: void 0, etag: etag ?? null, notModified: true };
|
|
155
|
+
}
|
|
156
|
+
if (res.status === 404) {
|
|
157
|
+
const err = new Error(`Not found: ${url}`);
|
|
158
|
+
err.code = "E_NOT_FOUND_UPSTREAM";
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
if (res.status === 429 || res.status >= 500) {
|
|
162
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
163
|
+
const wait = retryAfter
|
|
164
|
+
? Math.min(Number.parseInt(retryAfter, 10) * 1e3 || 2e3, 15e3)
|
|
165
|
+
: Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3);
|
|
166
|
+
lastErr = new Error(`Upstream ${res.status} for ${url}`);
|
|
167
|
+
if (attempt < retries) {
|
|
168
|
+
await sleep(wait);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
throw lastErr;
|
|
172
|
+
}
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const err = new Error(`Upstream ${res.status} for ${url}`);
|
|
175
|
+
err.code = "E_FETCH";
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
const json = await res.json();
|
|
179
|
+
return {
|
|
180
|
+
data: validate(json),
|
|
181
|
+
etag: res.headers.get("ETag"),
|
|
182
|
+
notModified: false,
|
|
183
|
+
};
|
|
184
|
+
} catch (err) {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
if (err.code === "E_NOT_FOUND_UPSTREAM") throw err;
|
|
187
|
+
if (err.name === "AbortError") {
|
|
188
|
+
lastErr = new Error(`Timeout fetching ${url}`);
|
|
189
|
+
} else {
|
|
190
|
+
lastErr = err;
|
|
191
|
+
}
|
|
192
|
+
if (attempt < retries && (!err.code || err.code === "E_FETCH")) {
|
|
193
|
+
if (err.code === "E_FETCH") throw err;
|
|
194
|
+
await sleep(Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
throw lastErr;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
throw lastErr;
|
|
201
|
+
}
|
|
202
|
+
function fetchAllTerms(opts, fetchFn) {
|
|
203
|
+
return fetchJson(
|
|
204
|
+
`${BANANA_BASE}/term/all`,
|
|
205
|
+
(u) => rawTermSchema.array().parse(u),
|
|
206
|
+
opts,
|
|
207
|
+
fetchFn,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
function fetchCurrentTerm(opts, fetchFn) {
|
|
211
|
+
return fetchJson(
|
|
212
|
+
`${BANANA_BASE}/term/current`,
|
|
213
|
+
(u) => rawTermSchema.parse(u),
|
|
214
|
+
opts,
|
|
215
|
+
fetchFn,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
function fetchSections(term, opts, fetchFn) {
|
|
219
|
+
return fetchJson(
|
|
220
|
+
`${BANANA_BASE}/sections/${term}`,
|
|
221
|
+
(u) => rawSectionsSchema.parse(u),
|
|
222
|
+
opts,
|
|
223
|
+
fetchFn,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
function fetchOfferingHistory(term, opts, fetchFn) {
|
|
227
|
+
return fetchJson(
|
|
228
|
+
`${BANANA_BASE}/offering-history/${term}`,
|
|
229
|
+
(u) => rawHistorySchema.parse(u),
|
|
230
|
+
opts,
|
|
231
|
+
fetchFn,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
function fetchCourseAreas(opts, fetchFn) {
|
|
235
|
+
return fetchJson(
|
|
236
|
+
`${BANANA_BASE}/course-areas`,
|
|
237
|
+
(u) =>
|
|
238
|
+
u.map((x) => ({
|
|
239
|
+
area: String(x.area),
|
|
240
|
+
description: String(x.description),
|
|
241
|
+
})),
|
|
242
|
+
opts,
|
|
243
|
+
fetchFn,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export {
|
|
248
|
+
rawSectionsSchema,
|
|
249
|
+
rawHistorySchema,
|
|
250
|
+
BANANA_BASE,
|
|
251
|
+
USER_AGENT,
|
|
252
|
+
fetchJson,
|
|
253
|
+
fetchAllTerms,
|
|
254
|
+
fetchCurrentTerm,
|
|
255
|
+
fetchSections,
|
|
256
|
+
fetchOfferingHistory,
|
|
257
|
+
fetchCourseAreas,
|
|
258
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../core/dist/chunk-BUUEIEGG.js
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
var rawDateSchema = z.object({
|
|
6
|
+
year: z.number(),
|
|
7
|
+
month: z.number().min(1).max(12),
|
|
8
|
+
day: z.number().min(1).max(31)
|
|
9
|
+
});
|
|
10
|
+
var rawCourseCodeSchema = z.object({
|
|
11
|
+
department: z.string(),
|
|
12
|
+
courseNumber: z.number(),
|
|
13
|
+
suffix: z.string(),
|
|
14
|
+
affiliation: z.string()
|
|
15
|
+
});
|
|
16
|
+
var rawScheduleSchema = z.object({
|
|
17
|
+
startTime: z.number(),
|
|
18
|
+
// seconds since midnight
|
|
19
|
+
endTime: z.number(),
|
|
20
|
+
days: z.array(z.string()),
|
|
21
|
+
locations: z.array(z.string())
|
|
22
|
+
});
|
|
23
|
+
var rawSectionSchema = z.object({
|
|
24
|
+
courseAreas: z.array(z.string()),
|
|
25
|
+
credits: z.number(),
|
|
26
|
+
permCount: z.number().default(0),
|
|
27
|
+
seatsTotal: z.number(),
|
|
28
|
+
seatsFilled: z.number(),
|
|
29
|
+
status: z.string(),
|
|
30
|
+
// O | C | R | U (keep raw; map to labels downstream)
|
|
31
|
+
startDate: rawDateSchema,
|
|
32
|
+
endDate: rawDateSchema,
|
|
33
|
+
instructors: z.array(z.object({ name: z.string() })),
|
|
34
|
+
course: z.object({
|
|
35
|
+
code: rawCourseCodeSchema,
|
|
36
|
+
title: z.string(),
|
|
37
|
+
description: z.string(),
|
|
38
|
+
primaryAssociation: z.string(),
|
|
39
|
+
potentialError: z.boolean().default(false)
|
|
40
|
+
}),
|
|
41
|
+
schedules: z.array(rawScheduleSchema),
|
|
42
|
+
potentialError: z.boolean().default(false),
|
|
43
|
+
identifier: z.object({
|
|
44
|
+
department: z.string(),
|
|
45
|
+
courseNumber: z.number(),
|
|
46
|
+
suffix: z.string(),
|
|
47
|
+
affiliation: z.string(),
|
|
48
|
+
sectionNumber: z.number(),
|
|
49
|
+
year: z.number(),
|
|
50
|
+
term: z.string(),
|
|
51
|
+
// FA | SP | SU
|
|
52
|
+
half: z.nullable(z.object({ prefix: z.string(), number: z.number() }))
|
|
53
|
+
})
|
|
54
|
+
});
|
|
55
|
+
var rawSectionsSchema = z.array(rawSectionSchema);
|
|
56
|
+
var rawHistoryEntrySchema = z.object({
|
|
57
|
+
code: rawCourseCodeSchema,
|
|
58
|
+
terms: z.array(z.object({ year: z.number(), term: z.string() }))
|
|
59
|
+
});
|
|
60
|
+
var rawHistorySchema = z.array(rawHistoryEntrySchema);
|
|
61
|
+
var rawTermSchema = z.object({ year: z.number(), term: z.string() });
|
|
62
|
+
var rawCourseAreaSchema = z.object({
|
|
63
|
+
area: z.string(),
|
|
64
|
+
description: z.string()
|
|
65
|
+
});
|
|
66
|
+
var termSchema = z.string().regex(/^(FA|SP|SU)\d{4}$/, "Term must look like FA2026");
|
|
67
|
+
var daySchema = z.enum(["M", "T", "W", "R", "F", "S", "U"]);
|
|
68
|
+
var statusSchema = z.enum(["O", "C", "R", "U"]);
|
|
69
|
+
var meetingSchema = z.object({
|
|
70
|
+
days: z.array(daySchema),
|
|
71
|
+
startMin: z.number(),
|
|
72
|
+
// minutes since midnight (math)
|
|
73
|
+
endMin: z.number(),
|
|
74
|
+
startStr: z.string(),
|
|
75
|
+
// "13:15" (quote)
|
|
76
|
+
endStr: z.string(),
|
|
77
|
+
start12: z.string(),
|
|
78
|
+
// "1:15 PM" (display)
|
|
79
|
+
end12: z.string(),
|
|
80
|
+
locations: z.array(z.string())
|
|
81
|
+
});
|
|
82
|
+
var sectionSchema = z.object({
|
|
83
|
+
id: z.string(),
|
|
84
|
+
// "CSCI 051 PO-01 FA2026" (canonical, frozen v1)
|
|
85
|
+
term: termSchema,
|
|
86
|
+
year: z.number(),
|
|
87
|
+
termCode: z.enum(["FA", "SP", "SU"]),
|
|
88
|
+
half: z.nullable(z.object({ prefix: z.string(), number: z.number() })),
|
|
89
|
+
dept: z.string(),
|
|
90
|
+
number: z.number(),
|
|
91
|
+
suffix: z.string(),
|
|
92
|
+
school: z.string(),
|
|
93
|
+
// affiliation, uppercase (PO/CM/HM/SC/PZ + KS/JP/…)
|
|
94
|
+
sectionNo: z.number(),
|
|
95
|
+
code: z.string(),
|
|
96
|
+
// "CSCI 051" (dept + zero-padded + suffix, no school)
|
|
97
|
+
title: z.string(),
|
|
98
|
+
description: z.string(),
|
|
99
|
+
areas: z.array(z.string()),
|
|
100
|
+
instructors: z.array(z.string()),
|
|
101
|
+
meetings: z.array(meetingSchema),
|
|
102
|
+
credits: z.number(),
|
|
103
|
+
status: statusSchema,
|
|
104
|
+
statusLabel: z.string(),
|
|
105
|
+
// Open | Closed | Reopened | Unknown
|
|
106
|
+
seats: z.object({
|
|
107
|
+
filled: z.number(),
|
|
108
|
+
total: z.number(),
|
|
109
|
+
available: z.number(),
|
|
110
|
+
permCount: z.number()
|
|
111
|
+
}),
|
|
112
|
+
startDate: z.string(),
|
|
113
|
+
// ISO
|
|
114
|
+
endDate: z.string(),
|
|
115
|
+
potentialError: z.boolean(),
|
|
116
|
+
primaryAssociation: z.string(),
|
|
117
|
+
affiliation: z.string()
|
|
118
|
+
});
|
|
119
|
+
var cacheMetaSchema = z.object({
|
|
120
|
+
term: z.string(),
|
|
121
|
+
fetched_at: z.string(),
|
|
122
|
+
etag: z.string().nullable(),
|
|
123
|
+
hash: z.string(),
|
|
124
|
+
count: z.number(),
|
|
125
|
+
source_url: z.string(),
|
|
126
|
+
ttl_kind: z.string(),
|
|
127
|
+
stale: z.boolean(),
|
|
128
|
+
source: z.enum(["live", "stale-cache", "offline-file"])
|
|
129
|
+
});
|
|
130
|
+
var BANANA_BASE = "https://banana.hyperschedule.io/v4";
|
|
131
|
+
var USER_AGENT = "5C-Index/0.1 (+research/edu; cache 60s)";
|
|
132
|
+
async function sleep(ms) {
|
|
133
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
134
|
+
}
|
|
135
|
+
async function fetchJson(url, validate, opts = {}, fetchFn = globalThis.fetch) {
|
|
136
|
+
const { etag, timeoutMs = 15e3, retries = 3 } = opts;
|
|
137
|
+
let lastErr = null;
|
|
138
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
139
|
+
const ctrl = new AbortController();
|
|
140
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetchFn(url, {
|
|
143
|
+
signal: ctrl.signal,
|
|
144
|
+
headers: {
|
|
145
|
+
Accept: "application/json",
|
|
146
|
+
"User-Agent": USER_AGENT,
|
|
147
|
+
...etag ? { "If-None-Match": etag } : {}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
if (res.status === 304) {
|
|
152
|
+
return { data: void 0, etag: etag ?? null, notModified: true };
|
|
153
|
+
}
|
|
154
|
+
if (res.status === 404) {
|
|
155
|
+
const err = new Error(`Not found: ${url}`);
|
|
156
|
+
err.code = "E_NOT_FOUND_UPSTREAM";
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
if (res.status === 429 || res.status >= 500) {
|
|
160
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
161
|
+
const wait = retryAfter ? Math.min(Number.parseInt(retryAfter, 10) * 1e3 || 2e3, 15e3) : Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3);
|
|
162
|
+
lastErr = new Error(`Upstream ${res.status} for ${url}`);
|
|
163
|
+
if (attempt < retries) {
|
|
164
|
+
await sleep(wait);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
throw lastErr;
|
|
168
|
+
}
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const err = new Error(`Upstream ${res.status} for ${url}`);
|
|
171
|
+
err.code = "E_FETCH";
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
const json = await res.json();
|
|
175
|
+
return {
|
|
176
|
+
data: validate(json),
|
|
177
|
+
etag: res.headers.get("ETag"),
|
|
178
|
+
notModified: false
|
|
179
|
+
};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
if (err.code === "E_NOT_FOUND_UPSTREAM") throw err;
|
|
183
|
+
if (err.name === "AbortError") {
|
|
184
|
+
lastErr = new Error(`Timeout fetching ${url}`);
|
|
185
|
+
} else {
|
|
186
|
+
lastErr = err;
|
|
187
|
+
}
|
|
188
|
+
if (attempt < retries && (!err.code || err.code === "E_FETCH")) {
|
|
189
|
+
if (err.code === "E_FETCH") throw err;
|
|
190
|
+
await sleep(Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
throw lastErr;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
throw lastErr;
|
|
197
|
+
}
|
|
198
|
+
function fetchAllTerms(opts, fetchFn) {
|
|
199
|
+
return fetchJson(
|
|
200
|
+
`${BANANA_BASE}/term/all`,
|
|
201
|
+
(u) => rawTermSchema.array().parse(u),
|
|
202
|
+
opts,
|
|
203
|
+
fetchFn
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
function fetchCurrentTerm(opts, fetchFn) {
|
|
207
|
+
return fetchJson(
|
|
208
|
+
`${BANANA_BASE}/term/current`,
|
|
209
|
+
(u) => rawTermSchema.parse(u),
|
|
210
|
+
opts,
|
|
211
|
+
fetchFn
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
function fetchSections(term, opts, fetchFn) {
|
|
215
|
+
return fetchJson(
|
|
216
|
+
`${BANANA_BASE}/sections/${term}`,
|
|
217
|
+
(u) => rawSectionsSchema.parse(u),
|
|
218
|
+
opts,
|
|
219
|
+
fetchFn
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
function fetchOfferingHistory(term, opts, fetchFn) {
|
|
223
|
+
return fetchJson(
|
|
224
|
+
`${BANANA_BASE}/offering-history/${term}`,
|
|
225
|
+
(u) => rawHistorySchema.parse(u),
|
|
226
|
+
opts,
|
|
227
|
+
fetchFn
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
function fetchCourseAreas(opts, fetchFn) {
|
|
231
|
+
return fetchJson(
|
|
232
|
+
`${BANANA_BASE}/course-areas`,
|
|
233
|
+
(u) => u.map((x) => ({
|
|
234
|
+
area: String(x.area),
|
|
235
|
+
description: String(x.description)
|
|
236
|
+
})),
|
|
237
|
+
opts,
|
|
238
|
+
fetchFn
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export {
|
|
243
|
+
rawSectionsSchema,
|
|
244
|
+
rawHistorySchema,
|
|
245
|
+
BANANA_BASE,
|
|
246
|
+
USER_AGENT,
|
|
247
|
+
fetchJson,
|
|
248
|
+
fetchAllTerms,
|
|
249
|
+
fetchCurrentTerm,
|
|
250
|
+
fetchSections,
|
|
251
|
+
fetchOfferingHistory,
|
|
252
|
+
fetchCourseAreas
|
|
253
|
+
};
|