spica
Version:
Supervisor, Coroutine, Channel, select, AtomicPromise, Cancellation, Cache, List, Queue, Stack, and some utils.
130 lines (124 loc) • 4.47 kB
text/typescript
import { Coroutine } from './coroutine';
import { Colistener } from './colistener';
import { Cancellation } from './cancellation';
import { Dict } from './dict';
import { ReadonlyURL } from './url';
import { noop } from './function';
export interface CofetchOptions {
method?: string;
headers?: Record<string, string> | Headers;
body?: Document | XMLHttpRequestBodyInit | null;
responseType?: XMLHttpRequestResponseType;
timeout?: number;
withCredentials?: boolean;
cache?: Dict<string, XMLHttpRequest>;
}
const memory = new WeakMap<XMLHttpRequest, { expiration: number; }>();
export function cofetch(url: string, options?: CofetchOptions): Cofetch {
return new Cofetch(url, options);
}
class Cofetch extends Coroutine<XMLHttpRequest, ProgressEvent> {
constructor(
url: string,
opts: CofetchOptions = {},
) {
super(async function* (this: Cofetch) {
this.finally(this.cancel);
assert(this.catch(console.error));
url = new ReadonlyURL(url, location.href).href;
opts = { ...opts };
opts.method = (opts.method || 'GET').toUpperCase();
opts.headers = new Headers(opts.headers);
const { method, headers, cache } = opts;
let state: 'load' | 'error' | 'abort' | 'timeout';
const key = `${method}:${url}`;
const xhr = new XMLHttpRequest();
const listener = new Colistener<ProgressEvent>(listener => {
xhr.addEventListener('loadstart', listener);
xhr.addEventListener('progress', listener);
xhr.addEventListener('loadend', listener);
for (const type of ['load', 'error', 'abort', 'timeout'] as const) {
xhr.addEventListener(type, () => state = type);
}
if (['GET', 'PUT'].includes(method) &&
cache && cache.has(key) && memory.has(cache.get(key)!) &&
Date.now() > memory.get(cache.get(key)!)!.expiration) {
// Bug
// @ts-ignore
headers.set('If-None-Match', cache.get(key)!.getResponseHeader('ETag')!);
}
fetch(xhr, url, opts);
this.cancellation.register(() => { xhr.readyState < 4 && xhr.abort(); });
return noop;
});
for await (const ev of listener) {
assert(ev instanceof ProgressEvent);
assert(['loadstart', 'progress', 'loadend'].includes(ev.type));
yield ev;
if (ev.type === 'loadend') break;
}
assert(state! !== undefined);
switch (state!) {
case 'load':
if (cache) switch (method) {
case 'GET':
case 'PUT':
if (`${xhr.status}`.match(/^2..$/)) {
const cc = new Map<string, string>(
xhr.getResponseHeader('Cache-Control')
// eslint-disable-next-line redos/no-vulnerable
? xhr.getResponseHeader('Cache-Control')!.trim().split(/\s*,\s*/)
.filter(v => v.length > 0)
.map(v => [...v.split('='), ''] as [string, string])
: []);
if (xhr.getResponseHeader('ETag') && !cc.has('no-store')) {
memory.set(xhr, {
expiration: cc.has('max-age') && !cc.has('no-cache')
? Date.now() + +cc.get('max-age')! * 1000 || 0
: 0,
});
cache.set(key, xhr);
}
else {
memory.delete(xhr);
cache.delete(key);
}
}
if (xhr.status === 304 && cache.has(key)) {
return cache.get(key)!;
}
break;
}
return xhr;
default:
return xhr;
}
}, { run: false });
this[Coroutine.init]();
}
private readonly cancellation = new Cancellation();
public cancel(): void {
this.cancellation.cancel();
}
}
function fetch(xhr: XMLHttpRequest, url: string, opts: CofetchOptions): void {
assert(xhr.readyState === 0);
assert(opts.method);
xhr.open(opts.method!, url);
for (const key of Object.keys(opts)) {
switch (key) {
case 'method':
case 'body':
case 'cache':
continue;
case 'headers':
(opts.headers as Headers).forEach(([name, value]) =>
void xhr.setRequestHeader(name, value));
continue;
default:
if (key in xhr) xhr[key] = opts[key];
continue;
}
}
xhr.send(opts.body);
}