Recipe: Use authFetch
The Plugin Base covers Plugin.fetch(), the auth-aware HTTP helper every plugin gets bound to its own lifecycle. authFetch is the same pipeline underneath, exported standalone for code that isn’t a plugin, a data layer, a prefetch routine, anything that needs the kit’s auth handling without a player around it.
What it does that a raw fetch doesn’t
auth.transformUrl(url) runs first, then Authorization: Bearer <token> is set from a resolved bearerToken (string, sync getter, or async getter, so a Vue ref or reactive store works directly), then auth.headers merges in. A 401 response triggers auth.refreshOnUnauthenticated() exactly once, then retries the original request with freshly resolved credentials, a 403 propagates immediately and is never retried or refreshed. 5xx and network failures retry per RetryConfig (see Typed Errors for the default policy table).
signal is required, not optional: every call site owns its own AbortController and cancellation is explicit, there’s no implicit “cancel when some object is garbage collected” behavior to reason about.
/**
* Recipe: use authFetch directly.
*
* `authFetch` is the same auth-aware pipeline the kit uses internally for
* setup-time playlist loads and that `Plugin.fetch()` wraps for plugin code —
* exported standalone for consumer code that needs it without a plugin around
* it (a data layer, a prefetch routine, a service worker message handler).
* `auth.transformUrl` runs first, then `Authorization: Bearer <token>` is set
* from `bearerToken`, then `auth.headers`, with 401 triggering one
* `refreshOnUnauthenticated()` + retry and 403 propagating immediately.
*/
import type { AuthConfig } from '@nomercy-entertainment/nomercy-player-core';
import { authFetch, isAuthError, isNetworkError } from '@nomercy-entertainment/nomercy-player-core';
let cachedToken = 'stale-token';
const auth: AuthConfig = {
bearerToken: () => cachedToken,
refreshOnUnauthenticated: async () => {
cachedToken = 'refreshed-token'; // real code exchanges a refresh token here
},
};
const controller = new AbortController();
try {
const profile = await authFetch<{ id: string; name: string }>({
url: 'https://api.example.com/profile',
auth,
signal: controller.signal,
responseType: 'json',
timeoutMs: 8000,
});
console.log(profile.name);
} catch (err) {
if (isAuthError(err)) {
console.log('auth failed even after refresh:', err.code); // e.g. 'core:auth/forbidden'
} else if (isNetworkError(err)) {
console.log('network exhausted its retry budget:', err.code);
} else {
throw err;
}
}
// Cancel any in-flight authFetch call the same way you'd cancel a raw fetch.
controller.abort();
Response typing
responseType discriminates how the body decodes, and only one variant accepts a transform callback. This is FetchOptions<T>, the shape Plugin.fetch() / this.fetch() accepts, defined in core/plugin/fetch.ts; authFetch() itself takes the lower-level AuthFetchOptions<T> (core/auth-fetch/types.ts) with the same three-way responseType split, plus url, auth, and signal, which Plugin.fetch() fills in for you from the plugin’s own lifecycle:
type FetchOptions<T>
= | { responseType?: 'text'; parser?: (raw: string) => T }
| { responseType: 'json' }
| { responseType: 'arrayBuffer' };
'text' is the default, and the only variant that takes a parser. With no parser, a 'text' fetch resolves to the raw response string (T = string). 'json' calls response.json() and 'arrayBuffer' calls response.arrayBuffer(), neither accepts a parser, invalid JSON throws the same core:network/parse-failed a parser failure does.
The parser callback
parser receives the raw response body as a string and returns the T that Plugin.fetch<T>() (or authFetch<T>()) resolves to. Reach for it for any body that isn’t JSON, a newline- or comma-delimited list, a custom sidecar format, anything your own endpoint returns as plain text:
class QueueSyncPlugin extends Plugin {
// The endpoint returns one queue item id per line as text/plain.
async loadRemoteQueueIds(): Promise<string[]> {
try {
return await this.fetch<string[]>('/api/queue/ids.txt', {
parser: raw => raw.split('\n').map(line => line.trim()).filter(Boolean),
});
}
catch (err) {
if (isNetworkError(err) && err.code === 'core:network/parse-failed') {
this.logger.warn('queue id list came back malformed, keeping the current queue');
return [];
}
throw err;
}
}
}
A parser that throws surfaces exactly like a malformed 'json' body: core:network/parse-failed, a NetworkError caught with isNetworkError(), never an uncaught exception from inside the fetch pipeline.
For a body that’s already JSON, skip parser and set responseType: 'json' instead:
interface QueueSnapshot {
ids: string[];
updatedAt: string;
}
class QueueSyncPlugin extends Plugin {
async loadRemoteQueueSnapshot(): Promise<QueueSnapshot> {
return this.fetch<QueueSnapshot>('/api/queue', { responseType: 'json' });
}
}
The type flows differently through the two branches. In the 'text' branch T is inferred from the parser’s own return type, so the explicit <string[]> above is optional: write { parser: (raw): string[] => ... } and the fetch resolves to Promise<string[]> on its own, because the parser is the one place your code runs to produce the value, so its return type is the one the compiler can hold you to. The 'json' and 'arrayBuffer' branches have no callback to infer from. You name T yourself (fetch<QueueSnapshot>) and the kit casts the decoded body to it unchecked, the same response.json() as T you would write by hand, with nothing validating the shape at runtime. So keep the explicit type parameter for bodies you trust, and reach for a parser when you want the transform itself to be type-checked.
isAuthError() and isNetworkError() are the same type guards Plugin.fetch() callers use to branch a catch block without an instanceof import.
Next steps
- Recipe: Provide a Custom URL Resolver: for URLs handed to something that isn’t
fetchat all, a<video>.src, a Cast receiver, a Worker.