avbridge 2.2.0 → 2.3.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/CHANGELOG.md +125 -1
- package/NOTICE.md +2 -2
- package/README.md +100 -74
- package/THIRD_PARTY_LICENSES.md +2 -2
- package/dist/avi-2JPBSHGA.js +183 -0
- package/dist/avi-2JPBSHGA.js.map +1 -0
- package/dist/avi-F6WZJK5T.cjs +185 -0
- package/dist/avi-F6WZJK5T.cjs.map +1 -0
- package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
- package/dist/avi-NJXAXUXK.js.map +1 -0
- package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
- package/dist/avi-W6L3BTWU.cjs.map +1 -0
- package/dist/{chunk-ILKDNBSE.js → chunk-2PGRFCWB.js} +59 -10
- package/dist/chunk-2PGRFCWB.js.map +1 -0
- package/dist/chunk-5YAWWKA3.js +18 -0
- package/dist/chunk-5YAWWKA3.js.map +1 -0
- package/dist/chunk-6UUT4BEA.cjs +219 -0
- package/dist/chunk-6UUT4BEA.cjs.map +1 -0
- package/dist/{chunk-OE66B34H.cjs → chunk-7RGG6ME7.cjs} +562 -94
- package/dist/chunk-7RGG6ME7.cjs.map +1 -0
- package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
- package/dist/chunk-DCSOQH2N.js.map +1 -0
- package/dist/chunk-F3LQJKXK.cjs +20 -0
- package/dist/chunk-F3LQJKXK.cjs.map +1 -0
- package/dist/chunk-IAYKFGFG.js +200 -0
- package/dist/chunk-IAYKFGFG.js.map +1 -0
- package/dist/chunk-NNVOHKXJ.cjs +204 -0
- package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
- package/dist/{chunk-C5VA5U5O.js → chunk-NV7ILLWH.js} +556 -92
- package/dist/chunk-NV7ILLWH.js.map +1 -0
- package/dist/{chunk-HZLQNKFN.cjs → chunk-QQXBPW72.js} +54 -15
- package/dist/chunk-QQXBPW72.js.map +1 -0
- package/dist/chunk-XKPSTC34.cjs +210 -0
- package/dist/chunk-XKPSTC34.cjs.map +1 -0
- package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
- package/dist/chunk-Z33SBWL5.cjs.map +1 -0
- package/dist/element-browser.js +631 -103
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +4 -4
- package/dist/element.d.cts +1 -1
- package/dist/element.d.ts +1 -1
- package/dist/element.js +3 -3
- package/dist/index.cjs +174 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -4
- package/dist/index.d.ts +48 -4
- package/dist/index.js +93 -12
- package/dist/index.js.map +1 -1
- package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
- package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-http-reader-AZLE7YFS.cjs.map} +1 -1
- package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
- package/dist/{libav-http-reader-NQJVY273.js.map → libav-http-reader-WXG3Z7AI.js.map} +1 -1
- package/dist/{player-DUyvltvy.d.cts → player-B6WB74RD.d.cts} +63 -3
- package/dist/{player-DUyvltvy.d.ts → player-B6WB74RD.d.ts} +63 -3
- package/dist/player.cjs +5500 -0
- package/dist/player.cjs.map +1 -0
- package/dist/player.d.cts +649 -0
- package/dist/player.d.ts +649 -0
- package/dist/player.js +5498 -0
- package/dist/player.js.map +1 -0
- package/dist/source-73CAH6HW.cjs +28 -0
- package/dist/{source-CN43EI7Z.cjs.map → source-73CAH6HW.cjs.map} +1 -1
- package/dist/source-F656KYYV.js +3 -0
- package/dist/{source-FFZ7TW2B.js.map → source-F656KYYV.js.map} +1 -1
- package/dist/source-QJR3OHTW.js +3 -0
- package/dist/source-QJR3OHTW.js.map +1 -0
- package/dist/source-VB74JQ7Z.cjs +28 -0
- package/dist/source-VB74JQ7Z.cjs.map +1 -0
- package/dist/variant-routing-434STYAB.js +3 -0
- package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
- package/dist/variant-routing-HONNAA6R.cjs +12 -0
- package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
- package/package.json +9 -1
- package/src/classify/rules.ts +27 -5
- package/src/convert/remux.ts +8 -0
- package/src/convert/transcode.ts +41 -8
- package/src/element/avbridge-player.ts +845 -0
- package/src/element/player-icons.ts +25 -0
- package/src/element/player-styles.ts +472 -0
- package/src/errors.ts +47 -0
- package/src/index.ts +23 -0
- package/src/player-element.ts +18 -0
- package/src/player.ts +127 -27
- package/src/plugins/builtin.ts +2 -2
- package/src/probe/avi.ts +4 -0
- package/src/probe/index.ts +40 -10
- package/src/strategies/fallback/audio-output.ts +31 -0
- package/src/strategies/fallback/decoder.ts +83 -2
- package/src/strategies/fallback/index.ts +34 -1
- package/src/strategies/fallback/variant-routing.ts +7 -13
- package/src/strategies/fallback/video-renderer.ts +129 -33
- package/src/strategies/hybrid/decoder.ts +131 -20
- package/src/strategies/hybrid/index.ts +36 -2
- package/src/strategies/remux/index.ts +13 -1
- package/src/strategies/remux/mse.ts +12 -2
- package/src/strategies/remux/pipeline.ts +6 -0
- package/src/subtitles/index.ts +7 -3
- package/src/types.ts +53 -1
- package/src/util/libav-http-reader.ts +5 -1
- package/src/util/source.ts +28 -8
- package/src/util/transport.ts +26 -0
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
- package/dist/avi-6SJLWIWW.cjs.map +0 -1
- package/dist/avi-GCGM7OJI.js.map +0 -1
- package/dist/chunk-C5VA5U5O.js.map +0 -1
- package/dist/chunk-HZLQNKFN.cjs.map +0 -1
- package/dist/chunk-ILKDNBSE.js.map +0 -1
- package/dist/chunk-J5MCMN3S.js +0 -27
- package/dist/chunk-J5MCMN3S.js.map +0 -1
- package/dist/chunk-L4NPOJ36.cjs.map +0 -1
- package/dist/chunk-NZU7W256.cjs +0 -29
- package/dist/chunk-NZU7W256.cjs.map +0 -1
- package/dist/chunk-OE66B34H.cjs.map +0 -1
- package/dist/chunk-WD2ZNQA7.js.map +0 -1
- package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
- package/dist/libav-http-reader-NQJVY273.js +0 -3
- package/dist/source-CN43EI7Z.cjs +0 -28
- package/dist/source-FFZ7TW2B.js +0 -3
- package/dist/variant-routing-GOHB2RZN.cjs +0 -12
- package/dist/variant-routing-JOBWXYKD.js +0 -3
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* `SourceBuffer` with an append queue that respects `updateend` backpressure.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { AvbridgeError, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED } from "../../errors.js";
|
|
7
|
+
|
|
6
8
|
export interface MseSinkOptions {
|
|
7
9
|
mime: string;
|
|
8
10
|
video: HTMLVideoElement;
|
|
@@ -23,10 +25,18 @@ export class MseSink {
|
|
|
23
25
|
|
|
24
26
|
constructor(private readonly options: MseSinkOptions) {
|
|
25
27
|
if (typeof MediaSource === "undefined") {
|
|
26
|
-
throw new
|
|
28
|
+
throw new AvbridgeError(
|
|
29
|
+
ERR_MSE_NOT_SUPPORTED,
|
|
30
|
+
"MediaSource Extensions (MSE) are not supported in this environment.",
|
|
31
|
+
"MSE is required for the remux strategy. Use a browser that supports MSE, or try the fallback strategy.",
|
|
32
|
+
);
|
|
27
33
|
}
|
|
28
34
|
if (!MediaSource.isTypeSupported(options.mime)) {
|
|
29
|
-
throw new
|
|
35
|
+
throw new AvbridgeError(
|
|
36
|
+
ERR_MSE_CODEC_NOT_SUPPORTED,
|
|
37
|
+
`This browser's MSE does not support "${options.mime}".`,
|
|
38
|
+
"The codec combination can't be played via remux in this browser. The player will try the next strategy automatically.",
|
|
39
|
+
);
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
this.mediaSource = new MediaSource();
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
export interface RemuxPipeline {
|
|
25
25
|
start(fromTime?: number, autoPlay?: boolean): Promise<void>;
|
|
26
26
|
seek(time: number, autoPlay?: boolean): Promise<void>;
|
|
27
|
+
/** Update the autoplay intent mid-flight — used when play() arrives after seek() but before the MseSink has been constructed. */
|
|
28
|
+
setAutoPlay(autoPlay: boolean): void;
|
|
27
29
|
destroy(): Promise<void>;
|
|
28
30
|
stats(): Record<string, unknown>;
|
|
29
31
|
}
|
|
@@ -248,6 +250,10 @@ export async function createRemuxPipeline(
|
|
|
248
250
|
console.error("[avbridge] remux pipeline reseek failed:", err);
|
|
249
251
|
});
|
|
250
252
|
},
|
|
253
|
+
setAutoPlay(autoPlay) {
|
|
254
|
+
pendingAutoPlay = autoPlay;
|
|
255
|
+
if (sink) sink.setPlayOnSeek(autoPlay);
|
|
256
|
+
},
|
|
251
257
|
async destroy() {
|
|
252
258
|
destroyed = true;
|
|
253
259
|
pumpToken++;
|
package/src/subtitles/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { SubtitleTrackInfo } from "../types.js";
|
|
1
|
+
import type { SubtitleTrackInfo, TransportConfig } from "../types.js";
|
|
2
|
+
import { fetchWith } from "../util/transport.js";
|
|
2
3
|
import { srtToVtt } from "./srt.js";
|
|
3
4
|
import { isVtt } from "./vtt.js";
|
|
4
5
|
|
|
@@ -98,7 +99,10 @@ export async function attachSubtitleTracks(
|
|
|
98
99
|
tracks: SubtitleTrackInfo[],
|
|
99
100
|
bag?: SubtitleResourceBag,
|
|
100
101
|
onError?: (err: Error, track: SubtitleTrackInfo) => void,
|
|
102
|
+
transport?: TransportConfig,
|
|
101
103
|
): Promise<void> {
|
|
104
|
+
const doFetch = fetchWith(transport);
|
|
105
|
+
|
|
102
106
|
// Clear existing dynamically-attached tracks.
|
|
103
107
|
for (const t of Array.from(video.querySelectorAll("track[data-avbridge]"))) {
|
|
104
108
|
t.remove();
|
|
@@ -109,14 +113,14 @@ export async function attachSubtitleTracks(
|
|
|
109
113
|
try {
|
|
110
114
|
let url = t.sidecarUrl;
|
|
111
115
|
if (t.format === "srt") {
|
|
112
|
-
const res = await
|
|
116
|
+
const res = await doFetch(t.sidecarUrl, transport?.requestInit);
|
|
113
117
|
const text = await res.text();
|
|
114
118
|
const vtt = srtToVtt(text);
|
|
115
119
|
const blob = new Blob([vtt], { type: "text/vtt" });
|
|
116
120
|
url = bag ? bag.createObjectURL(blob) : URL.createObjectURL(blob);
|
|
117
121
|
} else if (t.format === "vtt") {
|
|
118
122
|
// Validate quickly so a malformed file fails loudly here.
|
|
119
|
-
const res = await
|
|
123
|
+
const res = await doFetch(t.sidecarUrl, transport?.requestInit);
|
|
120
124
|
const text = await res.text();
|
|
121
125
|
if (!isVtt(text)) {
|
|
122
126
|
// eslint-disable-next-line no-console
|
package/src/types.ts
CHANGED
|
@@ -69,6 +69,8 @@ export type AudioCodec =
|
|
|
69
69
|
| "ra_288" // RealAudio 2.0 (28.8 kbps)
|
|
70
70
|
| "sipr" // RealAudio Sipr (voice codec)
|
|
71
71
|
| "atrac3" // Sony ATRAC3 (sometimes seen in .rm)
|
|
72
|
+
| "dts" // DTS (common in Blu-ray MKV rips)
|
|
73
|
+
| "truehd" // Dolby TrueHD (Blu-ray lossless)
|
|
72
74
|
| (string & {});
|
|
73
75
|
|
|
74
76
|
export interface VideoTrackInfo {
|
|
@@ -219,7 +221,7 @@ export interface Plugin {
|
|
|
219
221
|
name: string;
|
|
220
222
|
canHandle(context: MediaContext): boolean;
|
|
221
223
|
/** Returns a session if it claims the context, otherwise throws. */
|
|
222
|
-
execute(context: MediaContext, target: HTMLVideoElement): Promise<PlaybackSession>;
|
|
224
|
+
execute(context: MediaContext, target: HTMLVideoElement, transport?: TransportConfig): Promise<PlaybackSession>;
|
|
223
225
|
}
|
|
224
226
|
|
|
225
227
|
/** Player creation options. */
|
|
@@ -256,6 +258,38 @@ export interface CreatePlayerOptions {
|
|
|
256
258
|
* strategy in the fallback chain on failure or stall.
|
|
257
259
|
*/
|
|
258
260
|
autoEscalate?: boolean;
|
|
261
|
+
/**
|
|
262
|
+
* Behavior when the browser tab becomes hidden.
|
|
263
|
+
* - `"pause"` (default): auto-pause on hide, auto-resume on visible
|
|
264
|
+
* if the user had been playing. Matches YouTube, Netflix, and
|
|
265
|
+
* native media players. Prevents degraded playback from Chrome's
|
|
266
|
+
* background throttling of requestAnimationFrame and setTimeout.
|
|
267
|
+
* - `"continue"`: keep playing. Playback will degrade anyway due to
|
|
268
|
+
* browser throttling, but useful for consumers who want full
|
|
269
|
+
* control of visibility handling themselves.
|
|
270
|
+
*/
|
|
271
|
+
backgroundBehavior?: "pause" | "continue";
|
|
272
|
+
/**
|
|
273
|
+
* Extra {@link RequestInit} merged into every HTTP request the player
|
|
274
|
+
* makes (probe Range requests, subtitle fetches, libav HTTP reader).
|
|
275
|
+
* Headers are merged, not overwritten — so you can add `Authorization`
|
|
276
|
+
* without losing the player's `Range` header.
|
|
277
|
+
*/
|
|
278
|
+
requestInit?: RequestInit;
|
|
279
|
+
/**
|
|
280
|
+
* Custom fetch implementation. Defaults to `globalThis.fetch`. Useful
|
|
281
|
+
* for interceptors, logging, or environments without a global fetch.
|
|
282
|
+
*/
|
|
283
|
+
fetchFn?: FetchFn;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Signature-compatible with `globalThis.fetch`. */
|
|
287
|
+
export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
288
|
+
|
|
289
|
+
/** Internal transport config bundle. Not part of the public API. */
|
|
290
|
+
export interface TransportConfig {
|
|
291
|
+
requestInit?: RequestInit;
|
|
292
|
+
fetchFn?: FetchFn;
|
|
259
293
|
}
|
|
260
294
|
|
|
261
295
|
/** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
|
|
@@ -293,6 +327,24 @@ export interface ConvertOptions {
|
|
|
293
327
|
onProgress?: (info: ProgressInfo) => void;
|
|
294
328
|
/** When true, reject on any uncertain codec/container combo. Default: `false` (best-effort). */
|
|
295
329
|
strict?: boolean;
|
|
330
|
+
/**
|
|
331
|
+
* Write output progressively to a `WritableStream` instead of accumulating
|
|
332
|
+
* in memory. Use with the File System Access API (`showSaveFilePicker()`) to
|
|
333
|
+
* transcode files larger than available memory.
|
|
334
|
+
*
|
|
335
|
+
* When set, the returned `ConvertResult.blob` will be an empty Blob (the
|
|
336
|
+
* real data went to the stream). The caller is responsible for closing the
|
|
337
|
+
* stream after the returned promise resolves.
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```ts
|
|
341
|
+
* const handle = await showSaveFilePicker({ suggestedName: "output.mp4" });
|
|
342
|
+
* const writable = await handle.createWritable();
|
|
343
|
+
* const result = await transcode(file, { outputStream: writable });
|
|
344
|
+
* await writable.close();
|
|
345
|
+
* ```
|
|
346
|
+
*/
|
|
347
|
+
outputStream?: WritableStream;
|
|
296
348
|
}
|
|
297
349
|
|
|
298
350
|
/** Progress information passed to {@link ConvertOptions.onProgress}. */
|
|
@@ -102,9 +102,13 @@ export async function prepareLibavInput(
|
|
|
102
102
|
libav: LibavLikeWithBlob,
|
|
103
103
|
filename: string,
|
|
104
104
|
source: import("./source.js").NormalizedSource,
|
|
105
|
+
transport?: import("../types.js").TransportConfig,
|
|
105
106
|
): Promise<LibavInputHandle> {
|
|
106
107
|
if (source.kind === "url") {
|
|
107
|
-
const handle = await attachLibavHttpReader(libav, filename, source.url
|
|
108
|
+
const handle = await attachLibavHttpReader(libav, filename, source.url, {
|
|
109
|
+
requestInit: transport?.requestInit,
|
|
110
|
+
fetchFn: transport?.fetchFn,
|
|
111
|
+
});
|
|
108
112
|
return {
|
|
109
113
|
filename,
|
|
110
114
|
transport: "http-range",
|
package/src/util/source.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { ContainerKind, MediaInput } from "../types.js";
|
|
1
|
+
import type { ContainerKind, MediaInput, TransportConfig } from "../types.js";
|
|
2
|
+
import { mergeFetchInit, fetchWith } from "./transport.js";
|
|
3
|
+
import { AvbridgeError, ERR_PROBE_FETCH_FAILED } from "../errors.js";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Bytes needed by the sniffer to identify every container we recognize.
|
|
@@ -59,7 +61,10 @@ export function isInMemorySource(source: NormalizedSource): source is Extract<No
|
|
|
59
61
|
*
|
|
60
62
|
* For non-URL inputs, the bytes are already in memory and we just wrap them.
|
|
61
63
|
*/
|
|
62
|
-
export async function normalizeSource(
|
|
64
|
+
export async function normalizeSource(
|
|
65
|
+
source: MediaInput,
|
|
66
|
+
transport?: TransportConfig,
|
|
67
|
+
): Promise<NormalizedSource> {
|
|
63
68
|
if (source instanceof File) {
|
|
64
69
|
return {
|
|
65
70
|
kind: "blob",
|
|
@@ -82,7 +87,7 @@ export async function normalizeSource(source: MediaInput): Promise<NormalizedSou
|
|
|
82
87
|
}
|
|
83
88
|
if (typeof source === "string" || source instanceof URL) {
|
|
84
89
|
const url = source instanceof URL ? source.toString() : source;
|
|
85
|
-
return await fetchUrlForSniff(url, source);
|
|
90
|
+
return await fetchUrlForSniff(url, source, transport);
|
|
86
91
|
}
|
|
87
92
|
throw new TypeError("unsupported source type");
|
|
88
93
|
}
|
|
@@ -93,20 +98,35 @@ export async function normalizeSource(source: MediaInput): Promise<NormalizedSou
|
|
|
93
98
|
* we only read the first 32 KB and abort the rest of the response so we
|
|
94
99
|
* don't accidentally buffer a large file.
|
|
95
100
|
*/
|
|
96
|
-
async function fetchUrlForSniff(
|
|
101
|
+
async function fetchUrlForSniff(
|
|
102
|
+
url: string,
|
|
103
|
+
originalSource: MediaInput,
|
|
104
|
+
transport?: TransportConfig,
|
|
105
|
+
): Promise<NormalizedSource> {
|
|
97
106
|
const name = url.split("/").pop()?.split("?")[0] ?? undefined;
|
|
107
|
+
const doFetch = fetchWith(transport);
|
|
98
108
|
|
|
99
109
|
// First attempt: Range request for the sniff window.
|
|
100
110
|
let res: Response;
|
|
101
111
|
try {
|
|
102
|
-
res = await
|
|
112
|
+
res = await doFetch(url, mergeFetchInit(transport?.requestInit, {
|
|
103
113
|
headers: { Range: `bytes=0-${URL_SNIFF_RANGE_BYTES - 1}` },
|
|
104
|
-
});
|
|
114
|
+
})!);
|
|
105
115
|
} catch (err) {
|
|
106
|
-
throw new
|
|
116
|
+
throw new AvbridgeError(
|
|
117
|
+
ERR_PROBE_FETCH_FAILED,
|
|
118
|
+
`Failed to fetch source ${url}: ${(err as Error).message}`,
|
|
119
|
+
"Check that the URL is reachable and CORS is configured. If the source requires authentication, pass requestInit with credentials/headers.",
|
|
120
|
+
);
|
|
107
121
|
}
|
|
108
122
|
if (!res.ok && res.status !== 206) {
|
|
109
|
-
throw new
|
|
123
|
+
throw new AvbridgeError(
|
|
124
|
+
ERR_PROBE_FETCH_FAILED,
|
|
125
|
+
`Failed to fetch source ${url}: ${res.status} ${res.statusText}`,
|
|
126
|
+
res.status === 403 || res.status === 401
|
|
127
|
+
? "The server rejected the request. Pass requestInit with the required Authorization header or credentials."
|
|
128
|
+
: "Check that the URL is correct and the server is reachable.",
|
|
129
|
+
);
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
// Determine the total file size from Content-Range (preferred) or Content-Length.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FetchFn, TransportConfig } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Merge two RequestInit objects. Headers are merged (not overwritten) so
|
|
5
|
+
* the caller's auth headers coexist with the player's Range headers.
|
|
6
|
+
* Other fields (credentials, mode, signal, etc.) in `extra` override `base`.
|
|
7
|
+
*/
|
|
8
|
+
export function mergeFetchInit(
|
|
9
|
+
base: RequestInit | undefined,
|
|
10
|
+
extra: RequestInit | undefined,
|
|
11
|
+
): RequestInit | undefined {
|
|
12
|
+
if (!base && !extra) return undefined;
|
|
13
|
+
return {
|
|
14
|
+
...base,
|
|
15
|
+
...extra,
|
|
16
|
+
headers: {
|
|
17
|
+
...(base?.headers as Record<string, string> | undefined ?? {}),
|
|
18
|
+
...(extra?.headers as Record<string, string> | undefined ?? {}),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Return the fetch function from a TransportConfig, falling back to globalThis.fetch. */
|
|
24
|
+
export function fetchWith(transport?: TransportConfig): FetchFn {
|
|
25
|
+
return transport?.fetchFn ?? globalThis.fetch;
|
|
26
|
+
}
|