bsuir-iis-api 0.9.0 → 0.10.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/dist/index.js CHANGED
@@ -177,25 +177,7 @@ function isAbortError(error) {
177
177
  return false;
178
178
  }
179
179
 
180
- // src/client/http.ts
181
- var RETRIABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
182
- function buildUrl(baseUrl, path, query) {
183
- const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
184
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
185
- const url = new URL(`${normalizedBase}${normalizedPath}`);
186
- if (query) {
187
- for (const [key, value] of Object.entries(query)) {
188
- if (value === void 0 || value === null) {
189
- continue;
190
- }
191
- url.searchParams.set(key, String(value));
192
- }
193
- }
194
- return url.toString();
195
- }
196
- function sleep(ms) {
197
- return new Promise((resolve) => setTimeout(resolve, ms));
198
- }
180
+ // src/client/http/cache.ts
199
181
  function tryReadCache(config, key) {
200
182
  const entry = config.responseCache.get(key);
201
183
  if (!entry) {
@@ -213,6 +195,7 @@ function setCache(config, key, value) {
213
195
  return;
214
196
  }
215
197
  const now = Date.now();
198
+ config.responseCache.delete(key);
216
199
  config.responseCache.set(key, {
217
200
  value,
218
201
  expiresAt: now + config.cacheTtlMs,
@@ -227,22 +210,93 @@ function setCache(config, key, value) {
227
210
  config.responseCache.delete(k);
228
211
  }
229
212
  }
230
- while (config.responseCache.size > config.cacheMaxEntries) {
231
- const firstKey = config.responseCache.keys().next().value;
232
- if (firstKey === void 0) {
213
+ if (config.responseCache.size > config.cacheMaxEntries) {
214
+ const byLeastRecentlyUsed = [...config.responseCache.entries()].sort(
215
+ (a, b) => a[1].accessedAt - b[1].accessedAt
216
+ );
217
+ for (const [k] of byLeastRecentlyUsed) {
218
+ if (config.responseCache.size <= config.cacheMaxEntries) {
219
+ break;
220
+ }
221
+ config.responseCache.delete(k);
222
+ }
223
+ }
224
+ }
225
+
226
+ // src/client/http/response.ts
227
+ async function readBodyTextWithLimit(response, maxResponseBytes) {
228
+ const contentLengthHeader = response.headers.get("content-length");
229
+ if (contentLengthHeader) {
230
+ const parsedLength = Number(contentLengthHeader);
231
+ if (Number.isFinite(parsedLength) && parsedLength > maxResponseBytes) {
232
+ throw new BsuirApiError(
233
+ `Response body exceeds maxResponseBytes limit (${String(maxResponseBytes)} bytes)`,
234
+ response.status,
235
+ response.url,
236
+ null
237
+ );
238
+ }
239
+ }
240
+ if (!response.body || typeof response.body.getReader !== "function") {
241
+ const fallbackText = await response.text();
242
+ if (new TextEncoder().encode(fallbackText).byteLength > maxResponseBytes) {
243
+ throw new BsuirApiError(
244
+ `Response body exceeds maxResponseBytes limit (${String(maxResponseBytes)} bytes)`,
245
+ response.status,
246
+ response.url,
247
+ null
248
+ );
249
+ }
250
+ return fallbackText;
251
+ }
252
+ const reader = response.body.getReader();
253
+ const decoder = new TextDecoder();
254
+ let bytesRead = 0;
255
+ let text = "";
256
+ for (; ; ) {
257
+ const { done, value } = await reader.read();
258
+ if (done) {
233
259
  break;
234
260
  }
235
- config.responseCache.delete(firstKey);
261
+ bytesRead += value.byteLength;
262
+ if (bytesRead > maxResponseBytes) {
263
+ await reader.cancel();
264
+ throw new BsuirApiError(
265
+ `Response body exceeds maxResponseBytes limit (${String(maxResponseBytes)} bytes)`,
266
+ response.status,
267
+ response.url,
268
+ null
269
+ );
270
+ }
271
+ text += decoder.decode(value, { stream: true });
236
272
  }
273
+ text += decoder.decode();
274
+ return text;
237
275
  }
238
- function combineAbortSignals(first, second) {
239
- if (!first) {
240
- return second;
276
+ async function parseBody(response, maxResponseBytes) {
277
+ const contentType = response.headers.get("content-type") ?? "";
278
+ const declaredJson = contentType.includes("application/json");
279
+ const text = await readBodyTextWithLimit(response, maxResponseBytes);
280
+ if (text.length === 0) {
281
+ if (declaredJson) {
282
+ throw new BsuirApiError("Invalid JSON response payload", response.status, response.url, null);
283
+ }
284
+ return "";
241
285
  }
242
- if (!second) {
243
- return first;
286
+ try {
287
+ return JSON.parse(text);
288
+ } catch {
289
+ if (declaredJson) {
290
+ throw new BsuirApiError("Invalid JSON response payload", response.status, response.url, null);
291
+ }
292
+ return text;
244
293
  }
245
- return mergeSignals([first, second]);
294
+ }
295
+
296
+ // src/client/http/retry.ts
297
+ var RETRIABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
298
+ function sleep(ms) {
299
+ return new Promise((resolve) => setTimeout(resolve, ms));
246
300
  }
247
301
  function parseRetryAfterMs(retryAfter) {
248
302
  if (!retryAfter || retryAfter.trim().length === 0) {
@@ -276,24 +330,32 @@ function getRetryDelayMs(config, attempt, retryAfterHeader) {
276
330
  const jitterFactor = 0.75 + Math.random() * 0.5;
277
331
  return Math.floor(baseDelay * jitterFactor);
278
332
  }
279
- async function parseBody(response) {
280
- const contentType = response.headers.get("content-type") ?? "";
281
- const declaredJson = contentType.includes("application/json");
282
- const text = await response.text();
283
- if (text.length === 0) {
284
- if (declaredJson) {
285
- throw new BsuirApiError("Invalid JSON response payload", response.status, response.url, null);
333
+
334
+ // src/client/http/url.ts
335
+ function buildUrl(baseUrl, path, query) {
336
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
337
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
338
+ const url = new URL(`${normalizedBase}${normalizedPath}`);
339
+ if (query) {
340
+ for (const [key, value] of Object.entries(query)) {
341
+ if (value === void 0 || value === null) {
342
+ continue;
343
+ }
344
+ url.searchParams.set(key, String(value));
286
345
  }
287
- return "";
288
346
  }
289
- try {
290
- return JSON.parse(text);
291
- } catch {
292
- if (declaredJson) {
293
- throw new BsuirApiError("Invalid JSON response payload", response.status, response.url, null);
294
- }
295
- return text;
347
+ return url.toString();
348
+ }
349
+
350
+ // src/client/http/requestJson.ts
351
+ function combineAbortSignals(first, second) {
352
+ if (!first) {
353
+ return second;
296
354
  }
355
+ if (!second) {
356
+ return first;
357
+ }
358
+ return mergeSignals([first, second]);
297
359
  }
298
360
  function baseHookContext(method, path, endpoint, attempt, maxAttempts, query) {
299
361
  return {
@@ -327,8 +389,9 @@ async function requestJson(config, path, options = {}) {
327
389
  headers.set("Content-Type", "application/json");
328
390
  }
329
391
  const cacheKey = endpoint;
330
- const canUseCaching = config.cacheTtlMs !== void 0 && method === "GET" && !options.signal;
331
- const canUseDedup = config.dedupeInFlight && method === "GET" && !options.signal;
392
+ const hasActiveSignal = options.signal != null || config.signal?.aborted === true;
393
+ const canUseCaching = config.cacheTtlMs !== void 0 && method === "GET" && !hasActiveSignal;
394
+ const canUseDedup = config.dedupeInFlight && method === "GET" && !hasActiveSignal;
332
395
  if (canUseCaching) {
333
396
  const cached = tryReadCache(config, cacheKey);
334
397
  if (cached !== void 0) {
@@ -361,7 +424,7 @@ async function requestJson(config, path, options = {}) {
361
424
  }
362
425
  const response = await config.fetchImpl(endpoint, requestInit);
363
426
  if (!response.ok) {
364
- const errorBody = await parseBody(response);
427
+ const errorBody = await parseBody(response, config.maxResponseBytes);
365
428
  if (attempt < maxRetries && RETRIABLE_STATUS_CODES.has(response.status)) {
366
429
  const delayMs = getRetryDelayMs(config, attempt, response.headers.get("retry-after"));
367
430
  const retryCtx = {
@@ -388,7 +451,7 @@ async function requestJson(config, path, options = {}) {
388
451
  config.hooks.onError?.(errorCtx);
389
452
  throw apiError;
390
453
  }
391
- const parsed = await parseBody(response);
454
+ const parsed = await parseBody(response, config.maxResponseBytes);
392
455
  const responseCtx = {
393
456
  ...hookCtx,
394
457
  status: response.status,
@@ -590,119 +653,47 @@ function createAnnouncementsModule(config) {
590
653
  };
591
654
  }
592
655
 
593
- // src/modules/auditories.ts
594
- function createAuditoriesModule(config) {
656
+ // src/modules/createListModule.ts
657
+ function createListModule(config, endpoint) {
595
658
  return {
596
659
  /**
597
- * Returns the full list of auditories from `/auditories`.
598
- * If the caller aborts `options.signal`, the platform propagates `AbortError` (not wrapped by the SDK).
599
- *
600
- * @throws {BsuirApiError} When the API returns a non-success HTTP status
601
- * @throws {BsuirNetworkError} On transport failures after retries
602
- * @throws {BsuirTimeoutError} When the request exceeds `timeoutMs`
603
- */
660
+ * Returns all items from the configured endpoint.
661
+ */
604
662
  async listAll(options = {}) {
605
- const payload = await requestJson(config, "/auditories", {
663
+ const payload = await requestJson(config, endpoint, {
606
664
  signal: options.signal
607
665
  });
608
666
  if (config.validateResponses) {
609
- assertArrayResponse(payload, "/auditories");
667
+ assertArrayResponse(payload, endpoint);
610
668
  }
611
669
  return payload;
612
670
  }
613
671
  };
614
672
  }
615
673
 
674
+ // src/modules/auditories.ts
675
+ function createAuditoriesModule(config) {
676
+ return createListModule(config, "/auditories");
677
+ }
678
+
616
679
  // src/modules/departments.ts
617
680
  function createDepartmentsModule(config) {
618
- return {
619
- /**
620
- * Returns the full list of departments from `/departments`.
621
- * If the caller aborts `options.signal`, the platform propagates `AbortError` (not wrapped by the SDK).
622
- *
623
- * @throws {BsuirApiError} When the API returns a non-success HTTP status
624
- * @throws {BsuirNetworkError} On transport failures after retries
625
- * @throws {BsuirTimeoutError} When the request exceeds `timeoutMs`
626
- */
627
- async listAll(options = {}) {
628
- const payload = await requestJson(config, "/departments", {
629
- signal: options.signal
630
- });
631
- if (config.validateResponses) {
632
- assertArrayResponse(payload, "/departments");
633
- }
634
- return payload;
635
- }
636
- };
681
+ return createListModule(config, "/departments");
637
682
  }
638
683
 
639
684
  // src/modules/employees.ts
640
685
  function createEmployeesModule(config) {
641
- return {
642
- /**
643
- * Returns the full list of employees from `/employees/all`.
644
- * If the caller aborts `options.signal`, the platform propagates `AbortError` (not wrapped by the SDK).
645
- *
646
- * @throws {BsuirApiError} When the API returns a non-success HTTP status
647
- * @throws {BsuirNetworkError} On transport failures after retries
648
- * @throws {BsuirTimeoutError} When the request exceeds `timeoutMs`
649
- */
650
- async listAll(options = {}) {
651
- const payload = await requestJson(config, "/employees/all", {
652
- signal: options.signal
653
- });
654
- if (config.validateResponses) {
655
- assertArrayResponse(payload, "/employees/all");
656
- }
657
- return payload;
658
- }
659
- };
686
+ return createListModule(config, "/employees/all");
660
687
  }
661
688
 
662
689
  // src/modules/faculties.ts
663
690
  function createFacultiesModule(config) {
664
- return {
665
- /**
666
- * Returns the full list of faculties from `/faculties`.
667
- * If the caller aborts `options.signal`, the platform propagates `AbortError` (not wrapped by the SDK).
668
- *
669
- * @throws {BsuirApiError} When the API returns a non-success HTTP status
670
- * @throws {BsuirNetworkError} On transport failures after retries
671
- * @throws {BsuirTimeoutError} When the request exceeds `timeoutMs`
672
- */
673
- async listAll(options = {}) {
674
- const payload = await requestJson(config, "/faculties", {
675
- signal: options.signal
676
- });
677
- if (config.validateResponses) {
678
- assertArrayResponse(payload, "/faculties");
679
- }
680
- return payload;
681
- }
682
- };
691
+ return createListModule(config, "/faculties");
683
692
  }
684
693
 
685
694
  // src/modules/groups.ts
686
695
  function createGroupsModule(config) {
687
- return {
688
- /**
689
- * Returns the full list of student groups from `/student-groups`.
690
- * If the caller aborts `options.signal`, the platform propagates `AbortError` (not wrapped by the SDK).
691
- *
692
- * @throws {BsuirApiError} When the API returns a non-success HTTP status
693
- * @throws {BsuirNetworkError} On transport failures after retries
694
- * @throws {BsuirTimeoutError} When the request exceeds `timeoutMs`
695
- */
696
- async listAll(options = {}) {
697
- const payload = await requestJson(config, "/student-groups", {
698
- signal: options.signal
699
- });
700
- if (config.validateResponses) {
701
- assertArrayResponse(payload, "/student-groups");
702
- }
703
- return payload;
704
- }
705
- };
696
+ return createListModule(config, "/student-groups");
706
697
  }
707
698
 
708
699
  // src/utils/week.ts
@@ -741,7 +732,58 @@ function parseCurrentWeekInternal(payload, depth) {
741
732
  throw new BsuirValidationError("'currentWeek' response payload must be a positive integer");
742
733
  }
743
734
 
744
- // src/modules/schedule.ts
735
+ // src/modules/scheduleFilter.ts
736
+ function lessonAuditories(item) {
737
+ const { auditories } = item;
738
+ return Array.isArray(auditories) ? auditories : [];
739
+ }
740
+ function matchesFilter(item, filter) {
741
+ if (filter.source && item.source !== filter.source) {
742
+ return false;
743
+ }
744
+ if (filter.weekday && item.day !== filter.weekday) {
745
+ return false;
746
+ }
747
+ if (typeof filter.weekNumber === "number") {
748
+ if (!Array.isArray(item.weekNumber) || !item.weekNumber.includes(filter.weekNumber)) {
749
+ return false;
750
+ }
751
+ }
752
+ if (typeof filter.subgroup === "number" && item.numSubgroup !== filter.subgroup) {
753
+ return false;
754
+ }
755
+ if (filter.lessonTypeAbbrev) {
756
+ const types = Array.isArray(filter.lessonTypeAbbrev) ? filter.lessonTypeAbbrev : [filter.lessonTypeAbbrev];
757
+ if (!item.lessonTypeAbbrev || !types.includes(item.lessonTypeAbbrev)) {
758
+ return false;
759
+ }
760
+ }
761
+ if (filter.subjectQuery) {
762
+ const query = filter.subjectQuery.toLowerCase();
763
+ const haystack = `${item.subject} ${item.subjectFullName} ${item.note ?? ""}`.toLowerCase();
764
+ if (!haystack.includes(query)) {
765
+ return false;
766
+ }
767
+ }
768
+ if (filter.employeeUrlId) {
769
+ const employeeMatch = item.employees?.some((employee) => employee.urlId === filter.employeeUrlId);
770
+ if (!employeeMatch) {
771
+ return false;
772
+ }
773
+ }
774
+ if (filter.auditory && !lessonAuditories(item).includes(filter.auditory)) {
775
+ return false;
776
+ }
777
+ return true;
778
+ }
779
+ function filterLessons(response, filter) {
780
+ if (typeof filter.weekNumber === "number") {
781
+ assertPositiveInt(filter.weekNumber, "filter.weekNumber");
782
+ }
783
+ return response.lessons.filter((item) => matchesFilter(item, filter));
784
+ }
785
+
786
+ // src/modules/scheduleNormalize.ts
745
787
  var WEEKDAYS = [
746
788
  "\u041F\u043E\u043D\u0435\u0434\u0435\u043B\u044C\u043D\u0438\u043A",
747
789
  "\u0412\u0442\u043E\u0440\u043D\u0438\u043A",
@@ -750,7 +792,7 @@ var WEEKDAYS = [
750
792
  "\u041F\u044F\u0442\u043D\u0438\u0446\u0430",
751
793
  "\u0421\u0443\u0431\u0431\u043E\u0442\u0430"
752
794
  ];
753
- function lessonAuditories(item) {
795
+ function lessonAuditories2(item) {
754
796
  const { auditories } = item;
755
797
  return Array.isArray(auditories) ? auditories : [];
756
798
  }
@@ -766,7 +808,7 @@ function normalizeSchedule(response) {
766
808
  for (const day of WEEKDAYS) {
767
809
  const dayItems = safeSchedules[day] ?? [];
768
810
  const flattenedDayItems = dayItems.map((item) => {
769
- const auditories = lessonAuditories(item);
811
+ const auditories = lessonAuditories2(item);
770
812
  return {
771
813
  ...item,
772
814
  auditories,
@@ -779,12 +821,11 @@ function normalizeSchedule(response) {
779
821
  scheduleLessons.push(...flattenedDayItems);
780
822
  }
781
823
  for (const exam of safeExams) {
782
- const auditories = lessonAuditories(exam);
824
+ const auditories = lessonAuditories2(exam);
783
825
  const flattenedExam = {
784
826
  ...exam,
785
827
  auditories,
786
828
  day: null,
787
- // Exams are not grouped by weekday in API response.
788
829
  source: "exams"
789
830
  };
790
831
  lessons.push(flattenedExam);
@@ -800,51 +841,8 @@ function normalizeSchedule(response) {
800
841
  examLessons
801
842
  };
802
843
  }
803
- function matchesFilter(item, filter) {
804
- if (filter.source && item.source !== filter.source) {
805
- return false;
806
- }
807
- if (filter.weekday && item.day !== filter.weekday) {
808
- return false;
809
- }
810
- if (typeof filter.weekNumber === "number") {
811
- if (!Array.isArray(item.weekNumber) || !item.weekNumber.includes(filter.weekNumber)) {
812
- return false;
813
- }
814
- }
815
- if (typeof filter.subgroup === "number" && item.numSubgroup !== filter.subgroup) {
816
- return false;
817
- }
818
- if (filter.lessonTypeAbbrev) {
819
- const types = Array.isArray(filter.lessonTypeAbbrev) ? filter.lessonTypeAbbrev : [filter.lessonTypeAbbrev];
820
- if (!item.lessonTypeAbbrev || !types.includes(item.lessonTypeAbbrev)) {
821
- return false;
822
- }
823
- }
824
- if (filter.subjectQuery) {
825
- const query = filter.subjectQuery.toLowerCase();
826
- const haystack = `${item.subject} ${item.subjectFullName} ${item.note ?? ""}`.toLowerCase();
827
- if (!haystack.includes(query)) {
828
- return false;
829
- }
830
- }
831
- if (filter.employeeUrlId) {
832
- const employeeMatch = item.employees?.some((employee) => employee.urlId === filter.employeeUrlId);
833
- if (!employeeMatch) {
834
- return false;
835
- }
836
- }
837
- if (filter.auditory && !lessonAuditories(item).includes(filter.auditory)) {
838
- return false;
839
- }
840
- return true;
841
- }
842
- function filterLessons(response, filter) {
843
- if (typeof filter.weekNumber === "number") {
844
- assertPositiveInt(filter.weekNumber, "filter.weekNumber");
845
- }
846
- return response.lessons.filter((item) => matchesFilter(item, filter));
847
- }
844
+
845
+ // src/modules/scheduleApi.ts
848
846
  function createScheduleModule(config) {
849
847
  async function getGroup(groupNumber, options = {}) {
850
848
  assertGroupNumber(groupNumber, "groupNumber");
@@ -889,24 +887,35 @@ function createScheduleModule(config) {
889
887
  getEmployee,
890
888
  getGroupFiltered,
891
889
  getEmployeeFiltered,
890
+ /**
891
+ * Returns exams for a group.
892
+ */
892
893
  async getGroupExams(groupNumber, options = {}) {
893
894
  return getGroupFiltered(groupNumber, { source: "exams" }, options);
894
895
  },
896
+ /**
897
+ * Returns exams for an employee.
898
+ */
895
899
  async getEmployeeExams(urlId, options = {}) {
896
900
  return getEmployeeFiltered(urlId, { source: "exams" }, options);
897
901
  },
902
+ /**
903
+ * Returns regular schedule lessons of a specific subgroup for a group.
904
+ */
898
905
  async getGroupBySubgroup(groupNumber, subgroup, options = {}) {
899
906
  assertPositiveInt(subgroup, "subgroup");
900
907
  return getGroupFiltered(groupNumber, { source: "schedules", subgroup }, options);
901
908
  },
909
+ /**
910
+ * Returns regular schedule lessons of a specific subgroup for an employee.
911
+ */
902
912
  async getEmployeeBySubgroup(urlId, subgroup, options = {}) {
903
913
  assertPositiveInt(subgroup, "subgroup");
904
914
  return getEmployeeFiltered(urlId, { source: "schedules", subgroup }, options);
905
915
  },
906
916
  getCurrentWeek,
907
917
  /**
908
- * Calls IIS `/last-update-date/student-group`. That route is legacy and unsupported on the server;
909
- * it may return an error for newer group numbers (e.g. six-digit `524404`).
918
+ * Calls IIS `/last-update-date/student-group`.
910
919
  */
911
920
  async getLastUpdateByGroup(params, options = {}) {
912
921
  let query;
@@ -927,8 +936,7 @@ function createScheduleModule(config) {
927
936
  return payload;
928
937
  },
929
938
  /**
930
- * Calls IIS `/last-update-date/employee`. That route is legacy and unsupported on the server; prefer
931
- * not relying on it for critical cache logic.
939
+ * Calls IIS `/last-update-date/employee`.
932
940
  */
933
941
  async getLastUpdateByEmployee(params, options = {}) {
934
942
  let query;
@@ -953,29 +961,13 @@ function createScheduleModule(config) {
953
961
 
954
962
  // src/modules/specialities.ts
955
963
  function createSpecialitiesModule(config) {
956
- return {
957
- /**
958
- * Returns the full list of specialities from `/specialities`.
959
- * If the caller aborts `options.signal`, the platform propagates `AbortError` (not wrapped by the SDK).
960
- *
961
- * @throws {BsuirApiError} When the API returns a non-success HTTP status
962
- * @throws {BsuirNetworkError} On transport failures after retries
963
- * @throws {BsuirTimeoutError} When the request exceeds `timeoutMs`
964
- */
965
- async listAll(options = {}) {
966
- const payload = await requestJson(config, "/specialities", {
967
- signal: options.signal
968
- });
969
- if (config.validateResponses) {
970
- assertArrayResponse(payload, "/specialities");
971
- }
972
- return payload;
973
- }
974
- };
964
+ return createListModule(config, "/specialities");
975
965
  }
976
966
 
977
967
  // src/client/createClient.ts
978
968
  var DEFAULT_BASE_URL = "https://iis.bsuir.by/api/v1";
969
+ var DEFAULT_ALLOWED_BASE_URL_HOSTS = ["iis.bsuir.by"];
970
+ var DEFAULT_MAX_RESPONSE_BYTES = 5e6;
979
971
  var MAX_TIMEOUT_MS = 3e5;
980
972
  function resolveFetch(customFetch) {
981
973
  if (customFetch) {
@@ -999,6 +991,39 @@ function assertIntegerOption(value, name, minInclusive) {
999
991
  }
1000
992
  return value;
1001
993
  }
994
+ function normalizeBaseUrl(rawBaseUrl, allowInsecureHttp, allowedHosts) {
995
+ let parsed;
996
+ try {
997
+ parsed = new URL(rawBaseUrl);
998
+ } catch {
999
+ throw new BsuirConfigurationError("'baseUrl' must be a valid absolute URL");
1000
+ }
1001
+ if (parsed.username || parsed.password) {
1002
+ throw new BsuirConfigurationError("'baseUrl' must not include credentials");
1003
+ }
1004
+ if (parsed.search || parsed.hash) {
1005
+ throw new BsuirConfigurationError("'baseUrl' must not include query string or hash");
1006
+ }
1007
+ if (parsed.protocol !== "https:" && !(allowInsecureHttp && parsed.protocol === "http:")) {
1008
+ throw new BsuirConfigurationError(
1009
+ "'baseUrl' must use HTTPS. Set 'allowInsecureHttp: true' only for trusted local/test endpoints."
1010
+ );
1011
+ }
1012
+ const normalizedAllowedHosts = new Set(
1013
+ allowedHosts.map((host2) => host2.trim().toLowerCase()).filter((host2) => host2.length > 0)
1014
+ );
1015
+ if (normalizedAllowedHosts.size === 0) {
1016
+ throw new BsuirConfigurationError("'allowedBaseUrlHosts' must contain at least one hostname");
1017
+ }
1018
+ const host = parsed.hostname.toLowerCase();
1019
+ if (!normalizedAllowedHosts.has(host)) {
1020
+ throw new BsuirConfigurationError(
1021
+ `'baseUrl' host '${host}' is not allowed. Allowed hosts: ${[...normalizedAllowedHosts].join(", ")}`
1022
+ );
1023
+ }
1024
+ const normalizedPath = parsed.pathname.replace(/\/+$/, "");
1025
+ return `${parsed.origin}${normalizedPath}`;
1026
+ }
1002
1027
  function createInternalConfig(options) {
1003
1028
  const timeoutMs = assertIntegerOption(options.timeoutMs, "timeoutMs", 1) ?? 1e4;
1004
1029
  if (timeoutMs > MAX_TIMEOUT_MS) {
@@ -1011,13 +1036,16 @@ function createInternalConfig(options) {
1011
1036
  const retryMaxDelayMs = assertIntegerOption(options.retryMaxDelayMs, "retryMaxDelayMs", 0) ?? 3e3;
1012
1037
  const cacheTtlMs = assertIntegerOption(options.cache?.ttlMs, "cache.ttlMs", 1);
1013
1038
  const cacheMaxEntries = assertIntegerOption(options.cache?.maxEntries, "cache.maxEntries", 1) ?? 200;
1039
+ const maxResponseBytes = assertIntegerOption(options.maxResponseBytes, "maxResponseBytes", 1) ?? DEFAULT_MAX_RESPONSE_BYTES;
1040
+ const allowInsecureHttp = options.allowInsecureHttp ?? false;
1041
+ const allowedBaseUrlHosts = options.allowedBaseUrlHosts ?? DEFAULT_ALLOWED_BASE_URL_HOSTS;
1014
1042
  if (retryDelayMs > retryMaxDelayMs) {
1015
1043
  throw new BsuirConfigurationError(
1016
1044
  "'retryDelayMs' must be less than or equal to 'retryMaxDelayMs'"
1017
1045
  );
1018
1046
  }
1019
1047
  return {
1020
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
1048
+ baseUrl: normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL, allowInsecureHttp, allowedBaseUrlHosts),
1021
1049
  fetchImpl: resolveFetch(options.fetch),
1022
1050
  signal: options.signal,
1023
1051
  timeoutMs,
@@ -1029,7 +1057,8 @@ function createInternalConfig(options) {
1029
1057
  cacheTtlMs,
1030
1058
  cacheMaxEntries,
1031
1059
  dedupeInFlight: options.dedupeInFlight ?? true,
1032
- validateResponses: options.validateResponses ?? false,
1060
+ maxResponseBytes,
1061
+ validateResponses: options.validateResponses ?? true,
1033
1062
  hooks: options.hooks ?? {},
1034
1063
  responseCache: /* @__PURE__ */ new Map(),
1035
1064
  inFlightRequests: /* @__PURE__ */ new Map(),