@route-forge/core 1.0.0 → 1.0.3

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
@@ -26,17 +26,19 @@ var RouteCache = class {
26
26
  return this.getFromMemory(level);
27
27
  }
28
28
  const raw = this.backend.getItem(this.key(level));
29
- if (!raw) return void 0;
30
- try {
31
- const entry = JSON.parse(raw);
32
- if (this.isExpired(entry)) {
33
- this.del(level);
29
+ if (raw) {
30
+ try {
31
+ const entry = JSON.parse(raw);
32
+ if (this.isExpired(entry)) {
33
+ this.del(level);
34
+ return void 0;
35
+ }
36
+ return entry;
37
+ } catch {
34
38
  return void 0;
35
39
  }
36
- return entry;
37
- } catch {
38
- return void 0;
39
40
  }
41
+ return this.getFromMemory(level);
40
42
  }
41
43
  getFromMemory(level) {
42
44
  const entry = this.memory.get(level);
@@ -170,6 +172,16 @@ var HTTPError = class extends ForgeError {
170
172
  });
171
173
  }
172
174
  };
175
+ var RequestAbortedError = class extends ForgeError {
176
+ constructor(route, level, cause) {
177
+ super(`Request aborted${route ? ` for route "${route}"` : ""}`, {
178
+ code: "RF_FE_009",
179
+ route,
180
+ level,
181
+ cause
182
+ });
183
+ }
184
+ };
173
185
 
174
186
  // src/interceptors.ts
175
187
  var InterceptorManagerImpl = class {
@@ -238,12 +250,40 @@ function createInterceptorManager() {
238
250
  }
239
251
 
240
252
  // src/adapters/builtin-http.ts
253
+ function isPassthroughBody(body) {
254
+ if (body === null) return false;
255
+ return typeof FormData !== "undefined" && body instanceof FormData || typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams || typeof Blob !== "undefined" && body instanceof Blob || typeof ArrayBuffer !== "undefined" && (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) || typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
256
+ }
257
+ function isAbortError(err) {
258
+ const e = err;
259
+ return !!e && e.name === "AbortError";
260
+ }
261
+ function combineSignals(...signals) {
262
+ const valid = signals.filter((s) => !!s);
263
+ if (valid.length === 0) return void 0;
264
+ if (valid.length === 1) return valid[0];
265
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
266
+ return AbortSignal.any(valid);
267
+ }
268
+ const controller = new AbortController();
269
+ for (const s of valid) {
270
+ if (s.aborted) {
271
+ controller.abort(s.reason);
272
+ return controller.signal;
273
+ }
274
+ s.addEventListener("abort", () => controller.abort(s.reason), { once: true });
275
+ }
276
+ return controller.signal;
277
+ }
241
278
  function createBuiltinHttp(forgeInterceptors) {
242
279
  const requestMgr = forgeInterceptors?.request ?? createInterceptorManager();
243
280
  const responseMgr = forgeInterceptors?.response ?? createInterceptorManager();
244
281
  async function request(config) {
245
282
  const finalConfig = await runRequestInterceptors(requestMgr, config);
246
- const signal = finalConfig.timeout && finalConfig.timeout > 0 ? AbortSignal.timeout(finalConfig.timeout) : void 0;
283
+ const signal = combineSignals(
284
+ finalConfig.signal,
285
+ finalConfig.timeout && finalConfig.timeout > 0 ? AbortSignal.timeout(finalConfig.timeout) : void 0
286
+ );
247
287
  let url = finalConfig.url;
248
288
  if (finalConfig.paramsSerializer && finalConfig.params) {
249
289
  const qs = finalConfig.paramsSerializer(finalConfig.params);
@@ -251,15 +291,34 @@ function createBuiltinHttp(forgeInterceptors) {
251
291
  url = url.includes("?") ? `${url}&${qs}` : `${url}?${qs}`;
252
292
  }
253
293
  }
294
+ const headers = new Headers(finalConfig.headers);
254
295
  const fetchInit = {
255
296
  method: finalConfig.method,
256
- headers: new Headers(finalConfig.headers)
297
+ headers
257
298
  };
258
299
  if (signal) fetchInit.signal = signal;
259
300
  if (finalConfig.body !== void 0 && !["GET", "HEAD"].includes(finalConfig.method.toUpperCase())) {
260
- fetchInit.body = typeof finalConfig.body === "string" ? finalConfig.body : JSON.stringify(finalConfig.body);
301
+ if (typeof finalConfig.body === "string" || isPassthroughBody(finalConfig.body)) {
302
+ fetchInit.body = finalConfig.body;
303
+ } else {
304
+ fetchInit.body = JSON.stringify(finalConfig.body);
305
+ if (!headers.has("Content-Type")) {
306
+ headers.set("Content-Type", "application/json");
307
+ }
308
+ }
309
+ }
310
+ let res;
311
+ try {
312
+ res = await fetch(url, fetchInit);
313
+ } catch (e) {
314
+ if (isAbortError(e)) throw e;
315
+ throw new NetworkError(
316
+ e instanceof Error ? e.message : String(e),
317
+ finalConfig.route,
318
+ finalConfig.level,
319
+ e
320
+ );
261
321
  }
262
- const res = await fetch(url, fetchInit);
263
322
  const text = await res.text();
264
323
  let data = text;
265
324
  const contentType = res.headers.get("content-type") ?? "";
@@ -279,9 +338,21 @@ function createBuiltinHttp(forgeInterceptors) {
279
338
  data,
280
339
  config: finalConfig
281
340
  };
341
+ const source = res.status >= 200 && res.status < 300 ? Promise.resolve(responseData) : Promise.reject(
342
+ new HTTPError(
343
+ `HTTP ${res.status} for route "${finalConfig.route}" (${finalConfig.method} ${url})`,
344
+ {
345
+ route: finalConfig.route,
346
+ level: finalConfig.level,
347
+ status: res.status,
348
+ url,
349
+ method: finalConfig.method
350
+ }
351
+ )
352
+ );
282
353
  return runResponseInterceptors(
283
354
  responseMgr,
284
- Promise.resolve(responseData)
355
+ source
285
356
  );
286
357
  }
287
358
  const get = (url, config) => request({ ...config, url, method: "GET" });
@@ -305,6 +376,15 @@ function createBuiltinHttp(forgeInterceptors) {
305
376
  }
306
377
 
307
378
  // src/adapters/axios.ts
379
+ function toHeaders(raw) {
380
+ if (!raw) return new Headers();
381
+ const init = typeof raw.toJSON === "function" ? raw.toJSON() : raw;
382
+ try {
383
+ return new Headers(init);
384
+ } catch {
385
+ return new Headers();
386
+ }
387
+ }
308
388
  async function wrapAxiosAdapter() {
309
389
  let axios;
310
390
  try {
@@ -321,23 +401,43 @@ async function wrapAxiosAdapter() {
321
401
  if (!axios || typeof axios.request !== "function") return null;
322
402
  async function request(config) {
323
403
  const headers = { ...config.headers };
324
- const res = await axios.request({
325
- url: config.url,
326
- method: config.method,
327
- headers,
328
- data: config.body
329
- // axios 会处理 baseURL/transformRequest 等 defaults
330
- });
331
- return {
332
- route: config.route,
333
- level: config.level,
334
- method: config.method,
335
- url: config.url,
336
- status: res.status,
337
- headers: new Headers(res.headers ?? {}),
338
- data: res.data,
339
- config
340
- };
404
+ try {
405
+ const res = await axios.request({
406
+ url: config.url,
407
+ method: config.method,
408
+ headers,
409
+ data: config.body,
410
+ // axios 会处理 baseURL/transformRequest 等 defaults;timeout 透传保证超时语义与 builtin 一致
411
+ timeout: config.timeout,
412
+ signal: config.signal
413
+ // 请求取消信号(AbortSignal)
414
+ });
415
+ return {
416
+ route: config.route,
417
+ level: config.level,
418
+ method: config.method,
419
+ url: config.url,
420
+ status: res.status,
421
+ headers: toHeaders(res.headers),
422
+ data: res.data,
423
+ config
424
+ };
425
+ } catch (e) {
426
+ if (e && e.response) {
427
+ const resp = e.response;
428
+ return {
429
+ route: config.route,
430
+ level: config.level,
431
+ method: config.method,
432
+ url: config.url,
433
+ status: resp.status,
434
+ headers: toHeaders(resp.headers),
435
+ data: resp.data,
436
+ config
437
+ };
438
+ }
439
+ throw e;
440
+ }
341
441
  }
342
442
  return { request, interceptors: void 0 };
343
443
  }
@@ -426,6 +526,7 @@ var LoadingTracker = class {
426
526
  // src/forge.ts
427
527
  var DEFAULT_TIMEOUT = 3e4;
428
528
  var DEFAULT_CACHE_TTL = 3600;
529
+ var UNASSIGNED_LEVEL = "unassigned";
429
530
  function createRouteForge(options) {
430
531
  if (!options.endpoint) throw new TypeError("options.endpoint is required");
431
532
  const {
@@ -444,6 +545,8 @@ function createRouteForge(options) {
444
545
  let effectiveEager = explicitEager ?? [];
445
546
  let effectiveEndpoint = explicitEndpoint;
446
547
  let effectiveUrlPrefix = "";
548
+ let summaryUnassigned;
549
+ let backendHasUnassignedLevel = false;
447
550
  const summaryPromise = (async () => {
448
551
  try {
449
552
  const summaryUrl = explicitEndpoint;
@@ -469,6 +572,12 @@ function createRouteForge(options) {
469
572
  if (summary === null) {
470
573
  return;
471
574
  }
575
+ const schemaVersion = summary.schemaVersion ?? 1;
576
+ if (schemaVersion > 1) {
577
+ console.warn(
578
+ `[route-forge] backend schemaVersion=${schemaVersion} > client supported 1; some features may be unavailable`
579
+ );
580
+ }
472
581
  if (summary.config.endpoint_prefix && summary.config.endpoint_prefix !== explicitEndpoint) {
473
582
  console.warn(
474
583
  `[route-forge] backend endpoint_prefix "${summary.config.endpoint_prefix}" overrides frontend endpoint "${explicitEndpoint}"`
@@ -484,9 +593,17 @@ function createRouteForge(options) {
484
593
  );
485
594
  }
486
595
  const backendLevels = Object.keys(summary.levels);
596
+ backendHasUnassignedLevel = backendLevels.includes(UNASSIGNED_LEVEL);
597
+ if (Array.isArray(summary.unassigned) && summary.unassigned.length > 0 && !backendHasUnassignedLevel) {
598
+ summaryUnassigned = summary.unassigned;
599
+ }
600
+ const availableLevels = backendLevels.slice();
601
+ if (summaryUnassigned && !backendHasUnassignedLevel) {
602
+ availableLevels.push(UNASSIGNED_LEVEL);
603
+ }
487
604
  if (explicitLevels && explicitLevels.length > 0) {
488
- const intersection = explicitLevels.filter((l) => backendLevels.includes(l));
489
- const removed = explicitLevels.filter((l) => !backendLevels.includes(l));
605
+ const intersection = explicitLevels.filter((l) => availableLevels.includes(l));
606
+ const removed = explicitLevels.filter((l) => !availableLevels.includes(l));
490
607
  if (removed.length > 0) {
491
608
  console.warn(
492
609
  `[route-forge] levels not in backend summary and dropped: ${removed.join(", ")}`
@@ -494,7 +611,7 @@ function createRouteForge(options) {
494
611
  }
495
612
  effectiveLevels = intersection;
496
613
  } else {
497
- effectiveLevels = backendLevels;
614
+ effectiveLevels = availableLevels;
498
615
  }
499
616
  const backendEager = backendLevels.filter((lvl) => summary.levels[lvl]?.load === "eager");
500
617
  if (!explicitEager) {
@@ -578,7 +695,10 @@ function createRouteForge(options) {
578
695
  };
579
696
  const resp = await adp.request(config);
580
697
  if (!resp || resp.status < 200 || resp.status >= 300) {
581
- throw new Error(`Failed to load level ${level}: HTTP ${resp?.status}`);
698
+ throw new HTTPError(
699
+ `Failed to load level "${level}": HTTP ${resp?.status}`,
700
+ { level, status: resp?.status, url: buildUrl(level), method: "GET" }
701
+ );
582
702
  }
583
703
  const data = resp.data;
584
704
  return data;
@@ -590,6 +710,14 @@ function createRouteForge(options) {
590
710
  if (existing) return existing;
591
711
  const p = (async () => {
592
712
  try {
713
+ if (level === UNASSIGNED_LEVEL && !backendHasUnassignedLevel && summaryUnassigned) {
714
+ const routes = {};
715
+ for (const r of summaryUnassigned) {
716
+ routes[r.name] = { ...r, level: UNASSIGNED_LEVEL };
717
+ }
718
+ cache.set({ level: UNASSIGNED_LEVEL, routes, cache: null });
719
+ return;
720
+ }
593
721
  const resp = await fetchLevel(level);
594
722
  cache.set(resp);
595
723
  } finally {
@@ -612,27 +740,33 @@ function createRouteForge(options) {
612
740
  return buildRequestUrl(meta, params ?? {});
613
741
  }
614
742
  function buildRequestUrl(meta, params) {
615
- let uri = meta.uri;
616
743
  const defaults = meta.parameter_defaults ?? {};
617
744
  const missingRequired = [];
745
+ const values = {};
618
746
  for (const p of meta.parameters) {
619
747
  let v = params[p];
620
748
  if ((v === void 0 || v === null) && p in defaults) {
621
749
  v = defaults[p];
622
750
  }
623
751
  if (v === void 0 || v === null) {
624
- if (uri.includes(`{${p}?}`)) {
625
- uri = uri.replace(`{${p}?}`, "");
626
- continue;
752
+ if (!meta.uri.includes(`{${p}?}`)) {
753
+ missingRequired.push(p);
627
754
  }
628
- missingRequired.push(p);
629
755
  } else {
630
- uri = uri.replace(`{${p}?}`, encodeURIComponent(String(v))).replace(`{${p}}`, encodeURIComponent(String(v)));
756
+ values[p] = v;
631
757
  }
632
758
  }
633
759
  if (missingRequired.length > 0) {
634
760
  throw new MissingRouteParamError(meta.name, missingRequired);
635
761
  }
762
+ let uri = meta.uri.replace(/\{([^{}]+)\}/g, (match, raw) => {
763
+ const optional = raw.endsWith("?");
764
+ const name = optional ? raw.slice(0, -1) : raw;
765
+ if (values[name] !== void 0) {
766
+ return encodeURIComponent(String(values[name]));
767
+ }
768
+ return optional ? "" : match;
769
+ });
636
770
  uri = uri.replace(/\/+/g, "/").replace(/\/$/, "");
637
771
  if (effectiveUrlPrefix.includes("://")) {
638
772
  const prefix2 = effectiveUrlPrefix.endsWith("/") ? effectiveUrlPrefix.slice(0, -1) : effectiveUrlPrefix;
@@ -660,7 +794,17 @@ function createRouteForge(options) {
660
794
  return doApiCall(meta, params);
661
795
  }
662
796
  async function doApiCall(meta, params) {
663
- const { pathParams, query, body, headers } = resolveApiParams(params);
797
+ const {
798
+ pathParams,
799
+ query,
800
+ body,
801
+ headers,
802
+ timeout: perCallTimeout,
803
+ signal
804
+ } = resolveApiParams(params);
805
+ if (signal?.aborted) {
806
+ throw new RequestAbortedError(meta.name, meta.level, signal.reason);
807
+ }
664
808
  const method = pickMethod(meta);
665
809
  const urlWithQuery = appendQuery(buildRequestUrl(meta, pathParams), query);
666
810
  const config = {
@@ -671,11 +815,15 @@ function createRouteForge(options) {
671
815
  headers: { Accept: "application/json", ...headers ?? {} },
672
816
  body,
673
817
  params: pathParams,
674
- timeout,
818
+ timeout: perCallTimeout ?? timeout,
819
+ signal,
675
820
  meta
676
821
  };
677
822
  const adp = await ensureAdapter();
678
823
  const finalConfig = adp.runsInterceptors ? config : await runRequestInterceptors(requestInterceptors, config);
824
+ if (finalConfig.signal?.aborted) {
825
+ throw new RequestAbortedError(meta.name, meta.level, finalConfig.signal.reason);
826
+ }
679
827
  loadingTracker.start();
680
828
  try {
681
829
  const source = adp.request(finalConfig).then(
@@ -696,6 +844,9 @@ function createRouteForge(options) {
696
844
  },
697
845
  (err) => {
698
846
  if (err instanceof ForgeError) throw err;
847
+ if (isAbortError2(err, finalConfig.signal)) {
848
+ throw new RequestAbortedError(meta.name, meta.level, err);
849
+ }
699
850
  throw new NetworkError(
700
851
  err instanceof Error ? err.message : String(err),
701
852
  meta.name,
@@ -711,12 +862,17 @@ function createRouteForge(options) {
711
862
  }
712
863
  }
713
864
  function invalidate(level) {
714
- if (level) cache.del(level);
715
- else cache.clear();
865
+ if (level === void 0) {
866
+ cache.clear();
867
+ } else if (Array.isArray(level)) {
868
+ for (const lvl of level) cache.del(lvl);
869
+ } else {
870
+ cache.del(level);
871
+ }
716
872
  }
717
873
  function isLoaded(level) {
718
874
  if (level) return cache.get(level) !== void 0;
719
- return effectiveLevels.every((lvl) => cache.get(lvl) !== void 0);
875
+ return effectiveLevels.length > 0 && effectiveLevels.every((lvl) => cache.get(lvl) !== void 0);
720
876
  }
721
877
  function hasRoute(level, name) {
722
878
  return findRouteMeta(level, name) !== void 0;
@@ -790,6 +946,8 @@ function resolveApiParams(input) {
790
946
  query: rawQuery,
791
947
  body: rawBody,
792
948
  headers: rawHeaders,
949
+ timeout: perCallTimeout,
950
+ signal,
793
951
  ...flatRest
794
952
  } = input;
795
953
  const pathParams = explicitParams ? { ...explicitParams } : {};
@@ -822,9 +980,17 @@ function resolveApiParams(input) {
822
980
  pathParams.headers = rawHeaders;
823
981
  }
824
982
  }
825
- return { pathParams, query, body, headers };
983
+ return { pathParams, query, body, headers, timeout: perCallTimeout, signal };
984
+ }
985
+ function isAbortError2(err, signal) {
986
+ if (signal?.aborted) return true;
987
+ const e = err;
988
+ if (!e) return false;
989
+ if (e.name === "AbortError") return true;
990
+ if (e.code === "ERR_CANCELED") return true;
991
+ return false;
826
992
  }
827
993
 
828
- export { AdapterNotFoundError, ForgeError, HTTPError, InterceptorManagerImpl, InvalidInterceptorReturnError, LoadingTracker, MissingRouteParamError, NetworkError, RouteCache, UnknownLevelError, UnknownRouteError, createInterceptorManager, createRouteForge };
994
+ export { AdapterNotFoundError, ForgeError, HTTPError, InterceptorManagerImpl, InvalidInterceptorReturnError, LoadingTracker, MissingRouteParamError, NetworkError, RequestAbortedError, RouteCache, UnknownLevelError, UnknownRouteError, createInterceptorManager, createRouteForge };
829
995
  //# sourceMappingURL=index.js.map
830
996
  //# sourceMappingURL=index.js.map