mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 05:48:59 +08:00
feat(core): improve mobile perf (#15317)
#### PR Dependency Tree * **PR #15317** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Virtualized mobile navigation with shell navigation and interactive swipe menus; coordinated mobile back handling with interactive phases/state restoration. * Added shared auth request proxy and message-port based token handling across mobile and worker flows. * **Bug Fixes** * Hydrated remote worker error stacks for calls and observable errors. * Improved SQLite FTS/indexer and nbstore optional text handling; refined docs-search ref parsing and notification loading/retry. * **Refactor / UX** * Modal focus-preservation and pointer behavior updates; improved mobile menu controls and back gesture plugins. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -6,12 +6,15 @@
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./auth/channel": "./src/auth/channel.ts",
|
||||
"./auth/endpoint": "./src/auth/endpoint.ts",
|
||||
"./auth/request": "./src/auth/request.ts",
|
||||
"./nbstore/optional": "./src/nbstore/optional.ts",
|
||||
"./nbstore/payload": "./src/nbstore/payload.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/core": "workspace:*",
|
||||
"@capacitor/core": "^7.0.0"
|
||||
"@capacitor/core": "^8.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { MessagePortAuthProvider, serveAuthRequests } from './channel';
|
||||
import { canonicalAuthEndpoint, shouldRefreshAccessToken } from './endpoint';
|
||||
import { createAuthFetch, installAuthRequestProxy } from './request';
|
||||
|
||||
function stubXMLHttpRequest(completeOnSend = false) {
|
||||
const send = vi.fn();
|
||||
const abort = vi.fn();
|
||||
const instances = new Set<FakeXMLHttpRequest>();
|
||||
|
||||
class FakeXMLHttpRequest extends EventTarget {
|
||||
static readonly DONE = 4;
|
||||
readyState = 0;
|
||||
status = 0;
|
||||
responseType: XMLHttpRequestResponseType = '';
|
||||
response: unknown;
|
||||
responseText = '';
|
||||
timeout = 0;
|
||||
withCredentials = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
instances.add(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
}
|
||||
|
||||
setRequestHeader() {}
|
||||
|
||||
send(body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
send(body);
|
||||
if (completeOnSend) {
|
||||
this.readyState = FakeXMLHttpRequest.DONE;
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
abort();
|
||||
this.dispatchEvent(new Event('abort'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
|
||||
return {
|
||||
send,
|
||||
abort,
|
||||
respond(status: number, responseText: string) {
|
||||
const current = [...instances].at(-1);
|
||||
if (!current) throw new Error('No XMLHttpRequest instance');
|
||||
current.status = status;
|
||||
current.responseText = responseText;
|
||||
current.readyState = FakeXMLHttpRequest.DONE;
|
||||
current.dispatchEvent(new Event('readystatechange'));
|
||||
current.dispatchEvent(new Event('load'));
|
||||
current.dispatchEvent(new Event('loadend'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('canonicalAuthEndpoint', () => {
|
||||
test.each([
|
||||
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
|
||||
['https://affine.pro:443', 'https://affine.pro'],
|
||||
['http://localhost:80/path', 'http://localhost'],
|
||||
['http://localhost:8080/path', 'http://localhost:8080'],
|
||||
['capacitor://localhost/path', 'capacitor://localhost/path'],
|
||||
['invalid endpoint', 'invalid endpoint'],
|
||||
])('normalizes %s', (endpoint, expected) => {
|
||||
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRefreshAccessToken', () => {
|
||||
test.each(['ACCESS_TOKEN_EXPIRED', 'ACCESS_TOKEN_INVALID'])(
|
||||
'refreshes for %s',
|
||||
code => {
|
||||
expect(shouldRefreshAccessToken(code)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['INVALID_REFRESH_TOKEN', undefined, null])(
|
||||
'does not refresh for %s',
|
||||
code => {
|
||||
expect(shouldRefreshAccessToken(code)).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('auth request fetch', () => {
|
||||
test('injects the endpoint token', async () => {
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => 'access-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
const rawFetch = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 })
|
||||
);
|
||||
const fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
await fetch('https://example.com/api/workspaces/1/blobs/1');
|
||||
|
||||
expect(provider.getValidAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
expect(
|
||||
(rawFetch.mock.calls[0][0] as Request).headers.get('Authorization')
|
||||
).toBe('Bearer access-token');
|
||||
});
|
||||
|
||||
test('refreshes and replays an expired request once', async () => {
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => 'expired-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
const rawFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
const fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
const response = await fetch('https://example.com/graphql');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
expect(rawFetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
(rawFetch.mock.calls[1][0] as Request).headers.get('Authorization')
|
||||
).toBe('Bearer refreshed-token');
|
||||
});
|
||||
|
||||
test('does not attach a token when the endpoint has no session', async () => {
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => null),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
const rawFetch = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 })
|
||||
);
|
||||
const fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
await fetch('https://cdn.example.com/presigned/blob');
|
||||
|
||||
expect(
|
||||
(rawFetch.mock.calls[0][0] as Request).headers.has('Authorization')
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth request XMLHttpRequest', () => {
|
||||
test('does not send after abort while waiting for a token', async () => {
|
||||
let resolveToken: (token: string | null) => void = () => {};
|
||||
const token = new Promise<string | null>(resolve => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
const xhrCalls = stubXMLHttpRequest();
|
||||
installAuthRequestProxy({
|
||||
getValidAccessToken: vi.fn(() => token),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
});
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', 'https://example.com/graphql');
|
||||
xhr.send('body');
|
||||
xhr.abort();
|
||||
resolveToken('access-token');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(xhrCalls.abort).toHaveBeenCalledOnce();
|
||||
expect(xhrCalls.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not send a stale body after reopening', async () => {
|
||||
const tokenResolvers: ((token: string | null) => void)[] = [];
|
||||
const xhrCalls = stubXMLHttpRequest();
|
||||
installAuthRequestProxy({
|
||||
getValidAccessToken: vi.fn(
|
||||
() =>
|
||||
new Promise<string | null>(resolve => {
|
||||
tokenResolvers.push(resolve);
|
||||
})
|
||||
),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
});
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', 'https://example.com/first');
|
||||
xhr.send('first-body');
|
||||
xhr.open('POST', 'https://example.com/second');
|
||||
xhr.send('second-body');
|
||||
tokenResolvers[1](null);
|
||||
tokenResolvers[0](null);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(xhrCalls.send).toHaveBeenCalledOnce();
|
||||
expect(xhrCalls.send).toHaveBeenCalledWith('second-body');
|
||||
});
|
||||
|
||||
test('uses the native lifecycle when token lookup fails', async () => {
|
||||
const xhrCalls = stubXMLHttpRequest(true);
|
||||
installAuthRequestProxy({
|
||||
getValidAccessToken: vi.fn(async () => {
|
||||
throw new Error('token lookup failed');
|
||||
}),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
});
|
||||
const xhr = new XMLHttpRequest();
|
||||
const loadend = vi.fn();
|
||||
xhr.addEventListener('loadend', loadend);
|
||||
|
||||
xhr.open('POST', 'https://example.com/graphql');
|
||||
xhr.send('body');
|
||||
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledWith('body'));
|
||||
|
||||
expect(xhr.readyState).toBe(XMLHttpRequest.DONE);
|
||||
expect(loadend).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('preserves abort lifecycle while waiting to replay', async () => {
|
||||
let resolveRefresh: (token: string) => void = () => {};
|
||||
const refresh = new Promise<string>(resolve => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const xhrCalls = stubXMLHttpRequest();
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => null),
|
||||
refreshAccessToken: vi.fn(() => refresh),
|
||||
};
|
||||
installAuthRequestProxy(provider);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const loadend = vi.fn();
|
||||
xhr.addEventListener('loadend', loadend);
|
||||
|
||||
xhr.open('POST', 'https://example.com/graphql');
|
||||
xhr.send('body');
|
||||
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce());
|
||||
xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }));
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
|
||||
|
||||
xhr.abort();
|
||||
resolveRefresh('refreshed-token');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(loadend).toHaveBeenCalledOnce();
|
||||
expect(xhrCalls.send).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth message channel', () => {
|
||||
test('serves get-valid and refresh operations', async () => {
|
||||
const channel = new MessageChannel();
|
||||
const nativeProvider = {
|
||||
getValidAccessToken: vi.fn(async () => 'access-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
serveAuthRequests(channel.port1, nativeProvider);
|
||||
const workerProvider = new MessagePortAuthProvider();
|
||||
workerProvider.setPort(channel.port2);
|
||||
|
||||
await expect(
|
||||
workerProvider.getValidAccessToken('https://example.com')
|
||||
).resolves.toBe('access-token');
|
||||
await expect(
|
||||
workerProvider.refreshAccessToken('https://example.com')
|
||||
).resolves.toBe('refreshed-token');
|
||||
|
||||
expect(nativeProvider.getValidAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
expect(nativeProvider.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
});
|
||||
|
||||
test('maps terminal get-valid errors to an empty session', async () => {
|
||||
const channel = new MessageChannel();
|
||||
const error = Object.assign(new Error('expired'), {
|
||||
code: 'AUTH_SESSION_EXPIRED',
|
||||
});
|
||||
serveAuthRequests(channel.port1, {
|
||||
getValidAccessToken: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
refreshAccessToken: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
});
|
||||
const workerProvider = new MessagePortAuthProvider();
|
||||
workerProvider.setPort(channel.port2);
|
||||
|
||||
await expect(
|
||||
workerProvider.getValidAccessToken('https://example.com')
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
workerProvider.refreshAccessToken('https://example.com')
|
||||
).rejects.toThrow('AUTH_SESSION_EXPIRED');
|
||||
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AuthRequestProvider } from './request';
|
||||
|
||||
type AuthOperation = 'get-valid' | 'refresh';
|
||||
|
||||
type AuthRequest = {
|
||||
id: string;
|
||||
operation: AuthOperation;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
type AuthResponse = {
|
||||
id: string;
|
||||
token?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const terminalAuthErrors = new Set([
|
||||
'ACCESS_TOKEN_INVALID',
|
||||
'AUTH_SESSION_EXPIRED',
|
||||
'AUTH_SESSION_REVOKED',
|
||||
'REFRESH_TOKEN_INVALID',
|
||||
'REFRESH_TOKEN_REUSED',
|
||||
'UNSUPPORTED_CLIENT_VERSION',
|
||||
'AUTH_SESSION_EMPTY',
|
||||
]);
|
||||
|
||||
const temporaryAuthError = 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE';
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
return typeof error === 'object' &&
|
||||
error &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
: temporaryAuthError;
|
||||
}
|
||||
|
||||
export function serveAuthRequests(
|
||||
port: MessagePort,
|
||||
provider: AuthRequestProvider
|
||||
) {
|
||||
port.addEventListener('message', event => {
|
||||
const { id, operation, endpoint } = event.data as Partial<AuthRequest>;
|
||||
if (
|
||||
!id ||
|
||||
(operation !== 'get-valid' && operation !== 'refresh') ||
|
||||
!endpoint
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request =
|
||||
operation === 'refresh'
|
||||
? provider.refreshAccessToken(endpoint)
|
||||
: provider.getValidAccessToken(endpoint);
|
||||
request.then(
|
||||
token => port.postMessage({ id, token } satisfies AuthResponse),
|
||||
error =>
|
||||
port.postMessage({
|
||||
id,
|
||||
error: errorCode(error),
|
||||
} satisfies AuthResponse)
|
||||
);
|
||||
});
|
||||
port.start();
|
||||
}
|
||||
|
||||
export class MessagePortAuthProvider implements AuthRequestProvider {
|
||||
private port?: MessagePort;
|
||||
private nextRequestId = 0;
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (token: string | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
setPort(port: MessagePort) {
|
||||
this.port = port;
|
||||
port.addEventListener('message', event => {
|
||||
const { id, token, error } = event.data as Partial<AuthResponse>;
|
||||
if (!id) return;
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
|
||||
if (error) {
|
||||
pending.reject(new Error(error));
|
||||
} else {
|
||||
pending.resolve(token ?? null);
|
||||
}
|
||||
this.pending.delete(id);
|
||||
});
|
||||
port.start();
|
||||
}
|
||||
|
||||
async getValidAccessToken(endpoint: string) {
|
||||
try {
|
||||
return await this.request('get-valid', endpoint);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && terminalAuthErrors.has(error.message)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAccessToken(endpoint: string) {
|
||||
const token = await this.request('refresh', endpoint);
|
||||
if (!token) throw new Error(temporaryAuthError);
|
||||
return token;
|
||||
}
|
||||
|
||||
private request(operation: AuthOperation, endpoint: string) {
|
||||
const port = this.port;
|
||||
if (!port) return Promise.reject(new Error(temporaryAuthError));
|
||||
|
||||
const id = String(++this.nextRequestId);
|
||||
return new Promise<string | null>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(temporaryAuthError));
|
||||
}, 5000);
|
||||
this.pending.set(id, {
|
||||
resolve: token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
port.postMessage({ id, operation, endpoint } satisfies AuthRequest);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { canonicalAuthEndpoint } from './endpoint';
|
||||
|
||||
describe('canonicalAuthEndpoint', () => {
|
||||
test.each([
|
||||
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
|
||||
['https://affine.pro:443', 'https://affine.pro'],
|
||||
['http://localhost:80/path', 'http://localhost'],
|
||||
['http://localhost:8080/path', 'http://localhost:8080'],
|
||||
['capacitor://localhost/path', 'capacitor://localhost/path'],
|
||||
['invalid endpoint', 'invalid endpoint'],
|
||||
])('normalizes %s', (endpoint, expected) => {
|
||||
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -8,3 +8,7 @@ export function canonicalAuthEndpoint(endpoint: string) {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshAccessToken(code: unknown) {
|
||||
return code === 'ACCESS_TOKEN_EXPIRED' || code === 'ACCESS_TOKEN_INVALID';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { shouldRefreshAccessToken } from './endpoint';
|
||||
|
||||
export interface AuthRequestProvider {
|
||||
getValidAccessToken(endpoint: string): Promise<string | null>;
|
||||
refreshAccessToken(endpoint: string): Promise<string>;
|
||||
}
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
try {
|
||||
const parsed = new URL(
|
||||
url,
|
||||
globalThis.location?.origin ?? 'http://localhost'
|
||||
);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.origin
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthFetch(
|
||||
provider: AuthRequestProvider,
|
||||
rawFetch: typeof globalThis.fetch
|
||||
) {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
const retry = request.clone();
|
||||
const endpoint = authEndpointForUrl(request.url);
|
||||
const token = endpoint
|
||||
? await provider.getValidAccessToken(endpoint)
|
||||
: null;
|
||||
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await rawFetch(request);
|
||||
if (response.status !== 401 || !endpoint) return response;
|
||||
|
||||
const body = await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
if (!shouldRefreshAccessToken(body?.code)) return response;
|
||||
|
||||
const refreshed = await provider.refreshAccessToken(endpoint);
|
||||
retry.headers.set('Authorization', `Bearer ${refreshed}`);
|
||||
return rawFetch(retry);
|
||||
};
|
||||
}
|
||||
|
||||
export function installAuthRequestProxy(provider: AuthRequestProvider) {
|
||||
const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
private request:
|
||||
| {
|
||||
method: string;
|
||||
url: string | URL;
|
||||
async: boolean;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
| undefined;
|
||||
private readonly headers = new Map<string, string>();
|
||||
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
||||
private replaying = false;
|
||||
private hasReplayed = false;
|
||||
private sendVersion = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const suppressExpiredResponse = (event: Event) => {
|
||||
if (this.replaying) event.stopImmediatePropagation();
|
||||
};
|
||||
this.addEventListener('load', suppressExpiredResponse, true);
|
||||
this.addEventListener('loadend', suppressExpiredResponse, true);
|
||||
this.addEventListener(
|
||||
'readystatechange',
|
||||
event => {
|
||||
if (
|
||||
this.readyState !== rawXMLHttpRequest.DONE ||
|
||||
this.status !== 401 ||
|
||||
this.replaying ||
|
||||
this.hasReplayed ||
|
||||
!this.request?.async
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let code: unknown;
|
||||
try {
|
||||
code =
|
||||
this.responseType === 'json'
|
||||
? this.response?.code
|
||||
: JSON.parse(this.responseText)?.code;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!shouldRefreshAccessToken(code)) return;
|
||||
event.stopImmediatePropagation();
|
||||
this.replaying = true;
|
||||
this.hasReplayed = true;
|
||||
this.replayWithFreshToken().catch(() => {});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
async: boolean = true,
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
this.sendVersion++;
|
||||
this.request = { method, url, async, username, password };
|
||||
this.headers.clear();
|
||||
this.requestBody = undefined;
|
||||
this.replaying = false;
|
||||
this.hasReplayed = false;
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
url,
|
||||
async,
|
||||
username ?? undefined,
|
||||
password ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
override setRequestHeader(name: string, value: string): void {
|
||||
this.headers.set(name, value);
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
this.requestBody = body;
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const endpoint = authEndpointForUrl(
|
||||
requestUrl ?? globalThis.location.href
|
||||
);
|
||||
const sendVersion = this.sendVersion;
|
||||
|
||||
const sendWithToken = (token: string | null) => {
|
||||
if (sendVersion !== this.sendVersion) return;
|
||||
if (token) {
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
super.send(body);
|
||||
};
|
||||
|
||||
(endpoint
|
||||
? provider.getValidAccessToken(endpoint)
|
||||
: Promise.resolve(null)
|
||||
).then(sendWithToken, () => sendWithToken(null));
|
||||
}
|
||||
|
||||
override abort(): void {
|
||||
this.sendVersion++;
|
||||
this.replaying = false;
|
||||
super.abort();
|
||||
}
|
||||
|
||||
private async replayWithFreshToken() {
|
||||
const request = this.request;
|
||||
if (!request) return this.failReplay();
|
||||
const endpoint = authEndpointForUrl(request.url);
|
||||
if (!endpoint) return this.failReplay();
|
||||
const sendVersion = this.sendVersion;
|
||||
try {
|
||||
const token = await provider.refreshAccessToken(endpoint);
|
||||
if (sendVersion !== this.sendVersion) return;
|
||||
const responseType = this.responseType;
|
||||
const timeout = this.timeout;
|
||||
const withCredentials = this.withCredentials;
|
||||
super.open(
|
||||
request.method,
|
||||
request.url,
|
||||
true,
|
||||
request.username ?? undefined,
|
||||
request.password ?? undefined
|
||||
);
|
||||
this.replaying = false;
|
||||
this.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase() !== 'authorization') {
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
});
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
this.responseType = responseType;
|
||||
this.timeout = timeout;
|
||||
this.withCredentials = withCredentials;
|
||||
super.send(this.requestBody);
|
||||
} catch {
|
||||
if (sendVersion === this.sendVersion) {
|
||||
this.failReplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failReplay() {
|
||||
this.replaying = false;
|
||||
this.dispatchEvent(new Event('readystatechange'));
|
||||
this.dispatchEvent(new Event('error'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './auth/channel';
|
||||
export * from './auth/endpoint';
|
||||
export * from './auth/request';
|
||||
export * from './nbstore/optional';
|
||||
export * from './nbstore/payload';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeNativeOptional } from './optional';
|
||||
|
||||
describe('normalizeNativeOptional', () => {
|
||||
it.each([
|
||||
['string', 'summary', 'summary'],
|
||||
['null', null, null],
|
||||
['missing key', undefined, null],
|
||||
['wrong type', 42, 42],
|
||||
])('normalizes %s', (_, input, expected) => {
|
||||
expect(normalizeNativeOptional(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeNativeOptional<T>(value: T | null | undefined) {
|
||||
return value ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user