naja
Version:
Modern AJAX library for Nette Framework
244 lines (193 loc) • 8.48 kB
text/typescript
import {UIHandler} from './core/UIHandler';
import {FormsHandler} from './core/FormsHandler';
import {RedirectHandler} from './core/RedirectHandler';
import {SnippetHandler} from './core/SnippetHandler';
import {HistoryHandler} from './core/HistoryHandler';
import {SnippetCache} from './core/SnippetCache';
import {ScriptLoader} from './core/ScriptLoader';
import {TypedEventListener} from './utils';
export interface Options extends Record<string, any> {
fetch?: RequestInit;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Payload extends Record<string, any> {}
export class Naja extends EventTarget {
public readonly VERSION: number = 3;
private initialized: boolean = false;
public readonly uiHandler: UIHandler;
public readonly redirectHandler: RedirectHandler;
public readonly snippetHandler: SnippetHandler;
public readonly formsHandler: FormsHandler;
public readonly historyHandler: HistoryHandler;
public readonly snippetCache: SnippetCache;
public readonly scriptLoader: ScriptLoader;
private readonly extensions: Extension[] = [];
public defaultOptions: Options = {};
public constructor(
uiHandler?: { new(naja: Naja): UIHandler },
redirectHandler?: { new(naja: Naja): RedirectHandler },
snippetHandler?: { new(naja: Naja): SnippetHandler },
formsHandler?: { new(naja: Naja): FormsHandler },
historyHandler?: { new(naja: Naja): HistoryHandler },
snippetCache?: { new(naja: Naja): SnippetCache },
scriptLoader?: { new(naja: Naja): ScriptLoader },
) {
super();
this.uiHandler = new (uiHandler ?? UIHandler)(this);
this.redirectHandler = new (redirectHandler ?? RedirectHandler)(this);
this.snippetHandler = new (snippetHandler ?? SnippetHandler)(this);
this.formsHandler = new (formsHandler ?? FormsHandler)(this);
this.historyHandler = new (historyHandler ?? HistoryHandler)(this);
this.snippetCache = new (snippetCache ?? SnippetCache)(this);
this.scriptLoader = new (scriptLoader ?? ScriptLoader)(this);
}
public registerExtension(extension: Extension): void {
if (this.initialized) {
extension.initialize(this);
}
this.extensions.push(extension);
}
public initialize(defaultOptions: Options = {}): void {
if (this.initialized) {
throw new Error('Cannot initialize Naja, it is already initialized.');
}
this.defaultOptions = this.prepareOptions(defaultOptions);
this.extensions.forEach((extension) => extension.initialize(this));
this.dispatchEvent(new CustomEvent('init', {detail: {defaultOptions: this.defaultOptions}}));
this.initialized = true;
}
public prepareOptions(options?: Options): Options {
return {
...this.defaultOptions,
...options,
fetch: {
...this.defaultOptions.fetch,
...options?.fetch,
},
};
}
public async makeRequest(
method: string,
url: string | URL,
data: any | null = null,
options: Options = {},
): Promise<Payload> {
// normalize url to instanceof URL
if (typeof url === 'string') {
url = new URL(url, location.href);
}
options = this.prepareOptions(options);
const headers = new Headers(options.fetch!.headers || {});
const body = this.transformData(url, method, data);
const abortController = new AbortController();
const request = new Request(url.toString(), {
credentials: 'same-origin',
...options.fetch,
method,
headers,
body,
signal: abortController.signal,
});
// impersonate XHR so that Nette can detect isAjax()
request.headers.set('X-Requested-With', 'XMLHttpRequest');
// hint the server that Naja expects response to be JSON
request.headers.set('Accept', 'application/json');
if ( ! this.dispatchEvent(new CustomEvent('before', {cancelable: true, detail: {request, method, url: url.toString(), data, options}}))) {
return {};
}
const promise = window.fetch(request);
this.dispatchEvent(new CustomEvent('start', {detail: {request, promise, abortController, options}}));
let response, payload;
try {
response = await promise;
if ( ! response.ok) {
throw new HttpError(response);
}
payload = await response.json();
} catch (error: any) {
if (error.name === 'AbortError') {
this.dispatchEvent(new CustomEvent('abort', {detail: {request, error, options}}));
this.dispatchEvent(new CustomEvent('complete', {detail: {request, response, payload: undefined, error, options}}));
return {};
}
this.dispatchEvent(new CustomEvent('error', {detail: {request, response, error, options}}));
this.dispatchEvent(new CustomEvent('complete', {detail: {request, response, payload: undefined, error, options}}));
throw error;
}
this.dispatchEvent(new CustomEvent('payload', {detail: {request, response, payload, options}}));
this.dispatchEvent(new CustomEvent('success', {detail: {request, response, payload, options}}));
this.dispatchEvent(new CustomEvent('complete', {detail: {request, response, payload, error: undefined, options}}));
return payload;
}
private appendToQueryString(searchParams: URLSearchParams, key: string, value: any): void {
if (value === null || value === undefined) {
return;
}
if (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype) {
for (const [subkey, subvalue] of Object.entries(value)) {
this.appendToQueryString(searchParams, `${key}[${subkey}]`, subvalue);
}
} else {
searchParams.append(key, String(value));
}
}
private transformData(url: URL, method: string, data: any): BodyInit | null {
const isGet = ['GET', 'HEAD'].includes(method.toUpperCase());
// sending a form via GET -> serialize FormData into URL and return empty request body
if (isGet && data instanceof FormData) {
for (const [key, value] of data) {
if (value !== null && value !== undefined) {
url.searchParams.append(key, String(value));
}
}
return null;
}
// sending a POJO -> serialize it recursively into URLSearchParams
const isDataPojo = data !== null && Object.getPrototypeOf(data) === Object.prototype;
if (isDataPojo || Array.isArray(data)) {
// for GET requests, append values to URL and return empty request body
// otherwise build `new URLSearchParams()` to act as the request body
const transformedData = isGet ? url.searchParams : new URLSearchParams();
for (const [key, value] of Object.entries(data)) {
this.appendToQueryString(transformedData, key, value);
}
return isGet
? null
: transformedData;
}
return data;
}
declare public addEventListener: <K extends keyof NajaEventMap | string>(type: K, listener: TypedEventListener<Naja, K extends keyof NajaEventMap ? NajaEventMap[K] : CustomEvent>, options?: boolean | AddEventListenerOptions) => void;
declare public removeEventListener: <K extends keyof NajaEventMap | string>(type: K, listener: TypedEventListener<Naja, K extends keyof NajaEventMap ? NajaEventMap[K] : CustomEvent>, options?: boolean | AddEventListenerOptions) => void;
}
export interface Extension {
initialize(naja: Naja): void;
}
export class HttpError extends Error {
public readonly response: Response;
constructor(response: Response) {
const message = `HTTP ${response.status}: ${response.statusText}`;
super(message);
this.name = this.constructor.name;
this.stack = new Error(message).stack;
this.response = response;
}
}
export type InitEvent = CustomEvent<{defaultOptions: Options}>;
export type BeforeEvent = CustomEvent<{request: Request, method: string, url: string, data: any, options: Options}>;
export type StartEvent = CustomEvent<{request: Request, promise: Promise<Response>, abortController: AbortController, options: Options}>;
export type AbortEvent = CustomEvent<{request: Request, error: Error, options: Options}>;
export type PayloadEvent = CustomEvent<{request: Request, response: Response, payload: Payload, options: Options}>;
export type SuccessEvent = CustomEvent<{request: Request, response: Response, payload: Payload, options: Options}>;
export type ErrorEvent = CustomEvent<{request: Request, response: Response | undefined, error: Error, options: Options}>;
export type CompleteEvent = CustomEvent<{request: Request, response: Response | undefined, error: Error | undefined, payload: Payload | undefined, options: Options}>;
interface NajaEventMap {
init: InitEvent;
before: BeforeEvent;
start: StartEvent;
abort: AbortEvent;
payload: PayloadEvent;
success: SuccessEvent;
error: ErrorEvent;
complete: CompleteEvent;
}