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