hyperstack-react 0.5.9 → 0.6.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/dist/index.d.ts +15 -0
- package/dist/index.esm.js +535 -32
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +535 -32
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.esm.js
CHANGED
|
@@ -6768,6 +6768,19 @@ const DEFAULT_CONFIG = {
|
|
|
6768
6768
|
maxReconnectAttempts: 5,
|
|
6769
6769
|
maxEntriesPerView: DEFAULT_MAX_ENTRIES_PER_VIEW,
|
|
6770
6770
|
};
|
|
6771
|
+
/**
|
|
6772
|
+
* Determines if the error indicates the client should fetch a new token
|
|
6773
|
+
*/
|
|
6774
|
+
function shouldRefreshToken(code) {
|
|
6775
|
+
return [
|
|
6776
|
+
'TOKEN_EXPIRED',
|
|
6777
|
+
'TOKEN_INVALID_SIGNATURE',
|
|
6778
|
+
'TOKEN_INVALID_FORMAT',
|
|
6779
|
+
'TOKEN_INVALID_ISSUER',
|
|
6780
|
+
'TOKEN_INVALID_AUDIENCE',
|
|
6781
|
+
'TOKEN_KEY_NOT_FOUND',
|
|
6782
|
+
].includes(code);
|
|
6783
|
+
}
|
|
6771
6784
|
class HyperStackError extends Error {
|
|
6772
6785
|
constructor(message, code, details) {
|
|
6773
6786
|
super(message);
|
|
@@ -6776,6 +6789,42 @@ class HyperStackError extends Error {
|
|
|
6776
6789
|
this.name = 'HyperStackError';
|
|
6777
6790
|
}
|
|
6778
6791
|
}
|
|
6792
|
+
/**
|
|
6793
|
+
* Parse a kebab-case error code string (from X-Error-Code header) to AuthErrorCode
|
|
6794
|
+
*/
|
|
6795
|
+
function parseErrorCode(errorCode) {
|
|
6796
|
+
const codeMap = {
|
|
6797
|
+
'token-missing': 'TOKEN_MISSING',
|
|
6798
|
+
'token-expired': 'TOKEN_EXPIRED',
|
|
6799
|
+
'token-invalid-signature': 'TOKEN_INVALID_SIGNATURE',
|
|
6800
|
+
'token-invalid-format': 'TOKEN_INVALID_FORMAT',
|
|
6801
|
+
'token-invalid-issuer': 'TOKEN_INVALID_ISSUER',
|
|
6802
|
+
'token-invalid-audience': 'TOKEN_INVALID_AUDIENCE',
|
|
6803
|
+
'token-missing-claim': 'TOKEN_MISSING_CLAIM',
|
|
6804
|
+
'token-key-not-found': 'TOKEN_KEY_NOT_FOUND',
|
|
6805
|
+
'origin-mismatch': 'ORIGIN_MISMATCH',
|
|
6806
|
+
'origin-required': 'ORIGIN_REQUIRED',
|
|
6807
|
+
'origin-not-allowed': 'ORIGIN_NOT_ALLOWED',
|
|
6808
|
+
'rate-limit-exceeded': 'RATE_LIMIT_EXCEEDED',
|
|
6809
|
+
'websocket-session-rate-limit-exceeded': 'WEBSOCKET_SESSION_RATE_LIMIT_EXCEEDED',
|
|
6810
|
+
'connection-limit-exceeded': 'CONNECTION_LIMIT_EXCEEDED',
|
|
6811
|
+
'subscription-limit-exceeded': 'SUBSCRIPTION_LIMIT_EXCEEDED',
|
|
6812
|
+
'snapshot-limit-exceeded': 'SNAPSHOT_LIMIT_EXCEEDED',
|
|
6813
|
+
'egress-limit-exceeded': 'EGRESS_LIMIT_EXCEEDED',
|
|
6814
|
+
'invalid-static-token': 'INVALID_STATIC_TOKEN',
|
|
6815
|
+
'internal-error': 'INTERNAL_ERROR',
|
|
6816
|
+
'auth-required': 'AUTH_REQUIRED',
|
|
6817
|
+
'missing-authorization-header': 'MISSING_AUTHORIZATION_HEADER',
|
|
6818
|
+
'invalid-authorization-format': 'INVALID_AUTHORIZATION_FORMAT',
|
|
6819
|
+
'invalid-api-key': 'INVALID_API_KEY',
|
|
6820
|
+
'expired-api-key': 'EXPIRED_API_KEY',
|
|
6821
|
+
'user-not-found': 'USER_NOT_FOUND',
|
|
6822
|
+
'secret-key-required': 'SECRET_KEY_REQUIRED',
|
|
6823
|
+
'deployment-access-denied': 'DEPLOYMENT_ACCESS_DENIED',
|
|
6824
|
+
'quota-exceeded': 'QUOTA_EXCEEDED',
|
|
6825
|
+
};
|
|
6826
|
+
return codeMap[errorCode.toLowerCase()] || 'INTERNAL_ERROR';
|
|
6827
|
+
}
|
|
6779
6828
|
|
|
6780
6829
|
const GZIP_MAGIC_0 = 0x1f;
|
|
6781
6830
|
const GZIP_MAGIC_1 = 0x8b;
|
|
@@ -6823,27 +6872,364 @@ function isValidFrame(frame) {
|
|
|
6823
6872
|
['create', 'upsert', 'patch', 'delete'].includes(f['op']));
|
|
6824
6873
|
}
|
|
6825
6874
|
|
|
6875
|
+
const TOKEN_REFRESH_BUFFER_SECONDS = 60;
|
|
6876
|
+
const MIN_REFRESH_DELAY_MS = 1000;
|
|
6877
|
+
const DEFAULT_QUERY_PARAMETER = 'hs_token';
|
|
6878
|
+
const DEFAULT_HOSTED_TOKEN_ENDPOINT = 'https://api.usehyperstack.com/ws/sessions';
|
|
6879
|
+
const HOSTED_WEBSOCKET_SUFFIX = '.stack.usehyperstack.com';
|
|
6880
|
+
function normalizeTokenResult(result) {
|
|
6881
|
+
if (typeof result === 'string') {
|
|
6882
|
+
return { token: result };
|
|
6883
|
+
}
|
|
6884
|
+
return result;
|
|
6885
|
+
}
|
|
6886
|
+
function decodeBase64Url(value) {
|
|
6887
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
6888
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
6889
|
+
if (typeof atob === 'function') {
|
|
6890
|
+
return atob(padded);
|
|
6891
|
+
}
|
|
6892
|
+
const bufferCtor = globalThis.Buffer;
|
|
6893
|
+
if (bufferCtor) {
|
|
6894
|
+
return bufferCtor.from(padded, 'base64').toString('utf-8');
|
|
6895
|
+
}
|
|
6896
|
+
return undefined;
|
|
6897
|
+
}
|
|
6898
|
+
function parseJwtExpiry(token) {
|
|
6899
|
+
const parts = token.split('.');
|
|
6900
|
+
if (parts.length !== 3) {
|
|
6901
|
+
return undefined;
|
|
6902
|
+
}
|
|
6903
|
+
const payload = decodeBase64Url(parts[1] ?? '');
|
|
6904
|
+
if (!payload) {
|
|
6905
|
+
return undefined;
|
|
6906
|
+
}
|
|
6907
|
+
try {
|
|
6908
|
+
const decoded = JSON.parse(payload);
|
|
6909
|
+
return typeof decoded.exp === 'number' ? decoded.exp : undefined;
|
|
6910
|
+
}
|
|
6911
|
+
catch {
|
|
6912
|
+
return undefined;
|
|
6913
|
+
}
|
|
6914
|
+
}
|
|
6915
|
+
function normalizeExpiryTimestamp(expiresAt, expires_at) {
|
|
6916
|
+
return expiresAt ?? expires_at;
|
|
6917
|
+
}
|
|
6918
|
+
function isRefreshAuthResponseMessage(value) {
|
|
6919
|
+
if (typeof value !== 'object' || value === null) {
|
|
6920
|
+
return false;
|
|
6921
|
+
}
|
|
6922
|
+
const candidate = value;
|
|
6923
|
+
return typeof candidate['success'] === 'boolean'
|
|
6924
|
+
&& !('op' in candidate)
|
|
6925
|
+
&& !('entity' in candidate)
|
|
6926
|
+
&& !('mode' in candidate);
|
|
6927
|
+
}
|
|
6928
|
+
function isSocketIssueMessage(value) {
|
|
6929
|
+
if (typeof value !== 'object' || value === null) {
|
|
6930
|
+
return false;
|
|
6931
|
+
}
|
|
6932
|
+
const candidate = value;
|
|
6933
|
+
return candidate['type'] === 'error'
|
|
6934
|
+
&& typeof candidate['message'] === 'string'
|
|
6935
|
+
&& typeof candidate['code'] === 'string'
|
|
6936
|
+
&& typeof candidate['retryable'] === 'boolean'
|
|
6937
|
+
&& typeof candidate['fatal'] === 'boolean';
|
|
6938
|
+
}
|
|
6939
|
+
function isHostedHyperstackWebsocketUrl(websocketUrl) {
|
|
6940
|
+
try {
|
|
6941
|
+
return new URL(websocketUrl).hostname.toLowerCase().endsWith(HOSTED_WEBSOCKET_SUFFIX);
|
|
6942
|
+
}
|
|
6943
|
+
catch {
|
|
6944
|
+
return false;
|
|
6945
|
+
}
|
|
6946
|
+
}
|
|
6826
6947
|
class ConnectionManager {
|
|
6827
6948
|
constructor(config) {
|
|
6828
6949
|
this.ws = null;
|
|
6829
6950
|
this.reconnectAttempts = 0;
|
|
6830
6951
|
this.reconnectTimeout = null;
|
|
6831
6952
|
this.pingInterval = null;
|
|
6953
|
+
this.tokenRefreshTimeout = null;
|
|
6954
|
+
this.tokenRefreshInFlight = null;
|
|
6832
6955
|
this.currentState = 'disconnected';
|
|
6833
6956
|
this.subscriptionQueue = [];
|
|
6834
6957
|
this.activeSubscriptions = new Set();
|
|
6835
6958
|
this.frameHandlers = new Set();
|
|
6836
6959
|
this.stateHandlers = new Set();
|
|
6960
|
+
this.socketIssueHandlers = new Set();
|
|
6961
|
+
this.reconnectForTokenRefresh = false;
|
|
6837
6962
|
if (!config.websocketUrl) {
|
|
6838
6963
|
throw new HyperStackError('websocketUrl is required', 'INVALID_CONFIG');
|
|
6839
6964
|
}
|
|
6840
6965
|
this.websocketUrl = config.websocketUrl;
|
|
6966
|
+
this.hostedHyperstackUrl = isHostedHyperstackWebsocketUrl(config.websocketUrl);
|
|
6841
6967
|
this.reconnectIntervals = config.reconnectIntervals ?? DEFAULT_CONFIG.reconnectIntervals;
|
|
6842
|
-
this.maxReconnectAttempts =
|
|
6968
|
+
this.maxReconnectAttempts =
|
|
6969
|
+
config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts;
|
|
6970
|
+
this.authConfig = config.auth;
|
|
6843
6971
|
if (config.initialSubscriptions) {
|
|
6844
6972
|
this.subscriptionQueue.push(...config.initialSubscriptions);
|
|
6845
6973
|
}
|
|
6846
6974
|
}
|
|
6975
|
+
getTokenEndpoint() {
|
|
6976
|
+
if (this.authConfig?.tokenEndpoint) {
|
|
6977
|
+
return this.authConfig.tokenEndpoint;
|
|
6978
|
+
}
|
|
6979
|
+
if (this.hostedHyperstackUrl && this.authConfig?.publishableKey) {
|
|
6980
|
+
return DEFAULT_HOSTED_TOKEN_ENDPOINT;
|
|
6981
|
+
}
|
|
6982
|
+
return undefined;
|
|
6983
|
+
}
|
|
6984
|
+
getAuthStrategy() {
|
|
6985
|
+
if (this.authConfig?.token) {
|
|
6986
|
+
return { kind: 'static-token', token: this.authConfig.token };
|
|
6987
|
+
}
|
|
6988
|
+
if (this.authConfig?.getToken) {
|
|
6989
|
+
return { kind: 'token-provider', getToken: this.authConfig.getToken };
|
|
6990
|
+
}
|
|
6991
|
+
const tokenEndpoint = this.getTokenEndpoint();
|
|
6992
|
+
if (tokenEndpoint) {
|
|
6993
|
+
return { kind: 'token-endpoint', endpoint: tokenEndpoint };
|
|
6994
|
+
}
|
|
6995
|
+
return { kind: 'none' };
|
|
6996
|
+
}
|
|
6997
|
+
hasRefreshableAuth() {
|
|
6998
|
+
const strategy = this.getAuthStrategy();
|
|
6999
|
+
return strategy.kind === 'token-provider' || strategy.kind === 'token-endpoint';
|
|
7000
|
+
}
|
|
7001
|
+
updateTokenState(result) {
|
|
7002
|
+
const normalized = normalizeTokenResult(result);
|
|
7003
|
+
if (!normalized.token) {
|
|
7004
|
+
throw new HyperStackError('Authentication provider returned an empty token', 'TOKEN_INVALID');
|
|
7005
|
+
}
|
|
7006
|
+
this.currentToken = normalized.token;
|
|
7007
|
+
this.tokenExpiry = normalizeExpiryTimestamp(normalized.expiresAt, normalized.expires_at)
|
|
7008
|
+
?? parseJwtExpiry(normalized.token);
|
|
7009
|
+
if (this.isTokenExpired()) {
|
|
7010
|
+
throw new HyperStackError('Authentication token is expired', 'TOKEN_EXPIRED');
|
|
7011
|
+
}
|
|
7012
|
+
return normalized.token;
|
|
7013
|
+
}
|
|
7014
|
+
clearTokenState() {
|
|
7015
|
+
this.currentToken = undefined;
|
|
7016
|
+
this.tokenExpiry = undefined;
|
|
7017
|
+
}
|
|
7018
|
+
async getOrRefreshToken(forceRefresh = false) {
|
|
7019
|
+
if (!forceRefresh && this.currentToken && !this.isTokenExpired()) {
|
|
7020
|
+
return this.currentToken;
|
|
7021
|
+
}
|
|
7022
|
+
const strategy = this.getAuthStrategy();
|
|
7023
|
+
if (strategy.kind === 'none' && this.hostedHyperstackUrl) {
|
|
7024
|
+
throw new HyperStackError('Hosted Hyperstack websocket connections require auth.publishableKey, auth.getToken, auth.tokenEndpoint, or auth.token', 'AUTH_REQUIRED');
|
|
7025
|
+
}
|
|
7026
|
+
switch (strategy.kind) {
|
|
7027
|
+
case 'static-token':
|
|
7028
|
+
return this.updateTokenState(strategy.token);
|
|
7029
|
+
case 'token-provider':
|
|
7030
|
+
try {
|
|
7031
|
+
return this.updateTokenState(await strategy.getToken());
|
|
7032
|
+
}
|
|
7033
|
+
catch (error) {
|
|
7034
|
+
if (error instanceof HyperStackError) {
|
|
7035
|
+
throw error;
|
|
7036
|
+
}
|
|
7037
|
+
throw new HyperStackError('Failed to get authentication token', 'AUTH_REQUIRED', error);
|
|
7038
|
+
}
|
|
7039
|
+
case 'token-endpoint':
|
|
7040
|
+
try {
|
|
7041
|
+
return this.updateTokenState(await this.fetchTokenFromEndpoint(strategy.endpoint));
|
|
7042
|
+
}
|
|
7043
|
+
catch (error) {
|
|
7044
|
+
if (error instanceof HyperStackError) {
|
|
7045
|
+
throw error;
|
|
7046
|
+
}
|
|
7047
|
+
throw new HyperStackError('Failed to fetch authentication token from endpoint', 'AUTH_REQUIRED', error);
|
|
7048
|
+
}
|
|
7049
|
+
case 'none':
|
|
7050
|
+
return undefined;
|
|
7051
|
+
}
|
|
7052
|
+
}
|
|
7053
|
+
createTokenEndpointRequestBody() {
|
|
7054
|
+
return {
|
|
7055
|
+
websocket_url: this.websocketUrl,
|
|
7056
|
+
};
|
|
7057
|
+
}
|
|
7058
|
+
async fetchTokenFromEndpoint(tokenEndpoint) {
|
|
7059
|
+
const response = await fetch(tokenEndpoint, {
|
|
7060
|
+
method: 'POST',
|
|
7061
|
+
headers: {
|
|
7062
|
+
...(this.authConfig?.publishableKey
|
|
7063
|
+
? { Authorization: `Bearer ${this.authConfig.publishableKey}` }
|
|
7064
|
+
: {}),
|
|
7065
|
+
...(this.authConfig?.tokenEndpointHeaders ?? {}),
|
|
7066
|
+
'Content-Type': 'application/json',
|
|
7067
|
+
},
|
|
7068
|
+
credentials: this.authConfig?.tokenEndpointCredentials,
|
|
7069
|
+
body: JSON.stringify(this.createTokenEndpointRequestBody()),
|
|
7070
|
+
});
|
|
7071
|
+
if (!response.ok) {
|
|
7072
|
+
const rawError = await response.text();
|
|
7073
|
+
let parsedError;
|
|
7074
|
+
if (rawError) {
|
|
7075
|
+
try {
|
|
7076
|
+
parsedError = JSON.parse(rawError);
|
|
7077
|
+
}
|
|
7078
|
+
catch {
|
|
7079
|
+
parsedError = undefined;
|
|
7080
|
+
}
|
|
7081
|
+
}
|
|
7082
|
+
const wireErrorCode = response.headers.get('X-Error-Code')
|
|
7083
|
+
?? (typeof parsedError?.code === 'string' ? parsedError.code : null);
|
|
7084
|
+
const errorCode = wireErrorCode
|
|
7085
|
+
? parseErrorCode(wireErrorCode)
|
|
7086
|
+
: response.status === 429
|
|
7087
|
+
? 'QUOTA_EXCEEDED'
|
|
7088
|
+
: 'AUTH_REQUIRED';
|
|
7089
|
+
const errorMessage = typeof parsedError?.error === 'string' && parsedError.error.length > 0
|
|
7090
|
+
? parsedError.error
|
|
7091
|
+
: rawError || response.statusText || 'Authentication request failed';
|
|
7092
|
+
throw new HyperStackError(`Token endpoint returned ${response.status}: ${errorMessage}`, errorCode, {
|
|
7093
|
+
status: response.status,
|
|
7094
|
+
wireErrorCode,
|
|
7095
|
+
responseBody: rawError || null,
|
|
7096
|
+
});
|
|
7097
|
+
}
|
|
7098
|
+
const data = (await response.json());
|
|
7099
|
+
if (!data.token) {
|
|
7100
|
+
throw new HyperStackError('Token endpoint did not return a token', 'TOKEN_INVALID');
|
|
7101
|
+
}
|
|
7102
|
+
return data;
|
|
7103
|
+
}
|
|
7104
|
+
isTokenExpired() {
|
|
7105
|
+
if (!this.tokenExpiry) {
|
|
7106
|
+
return false;
|
|
7107
|
+
}
|
|
7108
|
+
return Date.now() >= (this.tokenExpiry - TOKEN_REFRESH_BUFFER_SECONDS) * 1000;
|
|
7109
|
+
}
|
|
7110
|
+
scheduleTokenRefresh() {
|
|
7111
|
+
this.clearTokenRefreshTimeout();
|
|
7112
|
+
if (!this.hasRefreshableAuth() || !this.tokenExpiry) {
|
|
7113
|
+
return;
|
|
7114
|
+
}
|
|
7115
|
+
const refreshAtMs = Math.max(Date.now() + MIN_REFRESH_DELAY_MS, (this.tokenExpiry - TOKEN_REFRESH_BUFFER_SECONDS) * 1000);
|
|
7116
|
+
const delayMs = Math.max(MIN_REFRESH_DELAY_MS, refreshAtMs - Date.now());
|
|
7117
|
+
this.tokenRefreshTimeout = setTimeout(() => {
|
|
7118
|
+
void this.refreshTokenInBackground();
|
|
7119
|
+
}, delayMs);
|
|
7120
|
+
}
|
|
7121
|
+
clearTokenRefreshTimeout() {
|
|
7122
|
+
if (this.tokenRefreshTimeout) {
|
|
7123
|
+
clearTimeout(this.tokenRefreshTimeout);
|
|
7124
|
+
this.tokenRefreshTimeout = null;
|
|
7125
|
+
}
|
|
7126
|
+
}
|
|
7127
|
+
async refreshTokenInBackground() {
|
|
7128
|
+
if (!this.hasRefreshableAuth()) {
|
|
7129
|
+
return;
|
|
7130
|
+
}
|
|
7131
|
+
if (this.tokenRefreshInFlight) {
|
|
7132
|
+
return this.tokenRefreshInFlight;
|
|
7133
|
+
}
|
|
7134
|
+
this.tokenRefreshInFlight = (async () => {
|
|
7135
|
+
const previousToken = this.currentToken;
|
|
7136
|
+
try {
|
|
7137
|
+
await this.getOrRefreshToken(true);
|
|
7138
|
+
if (previousToken &&
|
|
7139
|
+
this.currentToken &&
|
|
7140
|
+
this.currentToken !== previousToken &&
|
|
7141
|
+
this.ws?.readyState === WebSocket.OPEN) {
|
|
7142
|
+
// Try in-band auth refresh first
|
|
7143
|
+
const refreshed = await this.sendInBandAuthRefresh(this.currentToken);
|
|
7144
|
+
if (!refreshed) {
|
|
7145
|
+
// Fall back to reconnecting if in-band refresh failed
|
|
7146
|
+
this.rotateConnectionForTokenRefresh();
|
|
7147
|
+
}
|
|
7148
|
+
}
|
|
7149
|
+
this.scheduleTokenRefresh();
|
|
7150
|
+
}
|
|
7151
|
+
catch {
|
|
7152
|
+
this.scheduleTokenRefresh();
|
|
7153
|
+
}
|
|
7154
|
+
finally {
|
|
7155
|
+
this.tokenRefreshInFlight = null;
|
|
7156
|
+
}
|
|
7157
|
+
})();
|
|
7158
|
+
return this.tokenRefreshInFlight;
|
|
7159
|
+
}
|
|
7160
|
+
async sendInBandAuthRefresh(token) {
|
|
7161
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
7162
|
+
return false;
|
|
7163
|
+
}
|
|
7164
|
+
try {
|
|
7165
|
+
const message = JSON.stringify({
|
|
7166
|
+
type: 'refresh_auth',
|
|
7167
|
+
token: token,
|
|
7168
|
+
});
|
|
7169
|
+
this.ws.send(message);
|
|
7170
|
+
return true;
|
|
7171
|
+
}
|
|
7172
|
+
catch (error) {
|
|
7173
|
+
console.warn('Failed to send in-band auth refresh:', error);
|
|
7174
|
+
return false;
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
handleRefreshAuthResponse(message) {
|
|
7178
|
+
if (message.success) {
|
|
7179
|
+
const expiresAt = normalizeExpiryTimestamp(message.expiresAt, message.expires_at);
|
|
7180
|
+
if (typeof expiresAt === 'number') {
|
|
7181
|
+
this.tokenExpiry = expiresAt;
|
|
7182
|
+
}
|
|
7183
|
+
this.scheduleTokenRefresh();
|
|
7184
|
+
return true;
|
|
7185
|
+
}
|
|
7186
|
+
const errorCode = message.error ? parseErrorCode(message.error) : 'INTERNAL_ERROR';
|
|
7187
|
+
if (shouldRefreshToken(errorCode)) {
|
|
7188
|
+
this.clearTokenState();
|
|
7189
|
+
}
|
|
7190
|
+
this.rotateConnectionForTokenRefresh();
|
|
7191
|
+
return true;
|
|
7192
|
+
}
|
|
7193
|
+
handleSocketIssueMessage(message) {
|
|
7194
|
+
this.notifySocketIssue(message);
|
|
7195
|
+
if (message.fatal) {
|
|
7196
|
+
this.updateState('error', message.message);
|
|
7197
|
+
}
|
|
7198
|
+
return true;
|
|
7199
|
+
}
|
|
7200
|
+
rotateConnectionForTokenRefresh() {
|
|
7201
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.reconnectForTokenRefresh) {
|
|
7202
|
+
return;
|
|
7203
|
+
}
|
|
7204
|
+
this.reconnectForTokenRefresh = true;
|
|
7205
|
+
this.updateState('reconnecting');
|
|
7206
|
+
this.ws.close(1000, 'token refresh');
|
|
7207
|
+
}
|
|
7208
|
+
buildAuthUrl(token) {
|
|
7209
|
+
if (this.authConfig?.tokenTransport === 'bearer') {
|
|
7210
|
+
return this.websocketUrl;
|
|
7211
|
+
}
|
|
7212
|
+
if (!token) {
|
|
7213
|
+
return this.websocketUrl;
|
|
7214
|
+
}
|
|
7215
|
+
const separator = this.websocketUrl.includes('?') ? '&' : '?';
|
|
7216
|
+
return `${this.websocketUrl}${separator}${DEFAULT_QUERY_PARAMETER}=${encodeURIComponent(token)}`;
|
|
7217
|
+
}
|
|
7218
|
+
createWebSocket(url, token) {
|
|
7219
|
+
if (this.authConfig?.tokenTransport === 'bearer') {
|
|
7220
|
+
const init = token
|
|
7221
|
+
? { headers: { Authorization: `Bearer ${token}` } }
|
|
7222
|
+
: undefined;
|
|
7223
|
+
if (this.authConfig.websocketFactory) {
|
|
7224
|
+
return this.authConfig.websocketFactory(url, init);
|
|
7225
|
+
}
|
|
7226
|
+
throw new HyperStackError('auth.tokenTransport="bearer" requires auth.websocketFactory in this environment', 'INVALID_CONFIG');
|
|
7227
|
+
}
|
|
7228
|
+
if (this.authConfig?.websocketFactory) {
|
|
7229
|
+
return this.authConfig.websocketFactory(url);
|
|
7230
|
+
}
|
|
7231
|
+
return new WebSocket(url);
|
|
7232
|
+
}
|
|
6847
7233
|
getState() {
|
|
6848
7234
|
return this.currentState;
|
|
6849
7235
|
}
|
|
@@ -6859,21 +7245,52 @@ class ConnectionManager {
|
|
|
6859
7245
|
this.stateHandlers.delete(handler);
|
|
6860
7246
|
};
|
|
6861
7247
|
}
|
|
6862
|
-
|
|
7248
|
+
onSocketIssue(handler) {
|
|
7249
|
+
this.socketIssueHandlers.add(handler);
|
|
7250
|
+
return () => {
|
|
7251
|
+
this.socketIssueHandlers.delete(handler);
|
|
7252
|
+
};
|
|
7253
|
+
}
|
|
7254
|
+
notifySocketIssue(message) {
|
|
7255
|
+
const issue = {
|
|
7256
|
+
error: message.error,
|
|
7257
|
+
message: message.message,
|
|
7258
|
+
code: parseErrorCode(message.code),
|
|
7259
|
+
retryable: message.retryable,
|
|
7260
|
+
retryAfter: message.retry_after,
|
|
7261
|
+
suggestedAction: message.suggested_action,
|
|
7262
|
+
docsUrl: message.docs_url,
|
|
7263
|
+
fatal: message.fatal,
|
|
7264
|
+
};
|
|
7265
|
+
for (const handler of this.socketIssueHandlers) {
|
|
7266
|
+
handler(issue);
|
|
7267
|
+
}
|
|
7268
|
+
return issue;
|
|
7269
|
+
}
|
|
7270
|
+
async connect() {
|
|
7271
|
+
if (this.ws?.readyState === WebSocket.OPEN ||
|
|
7272
|
+
this.ws?.readyState === WebSocket.CONNECTING ||
|
|
7273
|
+
this.currentState === 'connecting') {
|
|
7274
|
+
return;
|
|
7275
|
+
}
|
|
7276
|
+
this.updateState('connecting');
|
|
7277
|
+
let token;
|
|
7278
|
+
try {
|
|
7279
|
+
token = await this.getOrRefreshToken();
|
|
7280
|
+
}
|
|
7281
|
+
catch (error) {
|
|
7282
|
+
this.updateState('error', error instanceof Error ? error.message : 'Failed to get token');
|
|
7283
|
+
throw error;
|
|
7284
|
+
}
|
|
7285
|
+
const wsUrl = this.buildAuthUrl(token);
|
|
6863
7286
|
return new Promise((resolve, reject) => {
|
|
6864
|
-
if (this.ws?.readyState === WebSocket.OPEN ||
|
|
6865
|
-
this.ws?.readyState === WebSocket.CONNECTING ||
|
|
6866
|
-
this.currentState === 'connecting') {
|
|
6867
|
-
resolve();
|
|
6868
|
-
return;
|
|
6869
|
-
}
|
|
6870
|
-
this.updateState('connecting');
|
|
6871
7287
|
try {
|
|
6872
|
-
this.ws =
|
|
7288
|
+
this.ws = this.createWebSocket(wsUrl, token);
|
|
6873
7289
|
this.ws.onopen = () => {
|
|
6874
7290
|
this.reconnectAttempts = 0;
|
|
6875
7291
|
this.updateState('connected');
|
|
6876
7292
|
this.startPingInterval();
|
|
7293
|
+
this.scheduleTokenRefresh();
|
|
6877
7294
|
this.resubscribeActive();
|
|
6878
7295
|
this.flushSubscriptionQueue();
|
|
6879
7296
|
resolve();
|
|
@@ -6888,14 +7305,23 @@ class ConnectionManager {
|
|
|
6888
7305
|
frame = await parseFrameFromBlob(event.data);
|
|
6889
7306
|
}
|
|
6890
7307
|
else if (typeof event.data === 'string') {
|
|
6891
|
-
|
|
7308
|
+
const parsed = JSON.parse(event.data);
|
|
7309
|
+
if (isRefreshAuthResponseMessage(parsed)) {
|
|
7310
|
+
this.handleRefreshAuthResponse(parsed);
|
|
7311
|
+
return;
|
|
7312
|
+
}
|
|
7313
|
+
if (isSocketIssueMessage(parsed)) {
|
|
7314
|
+
this.handleSocketIssueMessage(parsed);
|
|
7315
|
+
return;
|
|
7316
|
+
}
|
|
7317
|
+
frame = parseFrame(JSON.stringify(parsed));
|
|
6892
7318
|
}
|
|
6893
7319
|
else {
|
|
6894
7320
|
throw new HyperStackError(`Unsupported message type: ${typeof event.data}`, 'PARSE_ERROR');
|
|
6895
7321
|
}
|
|
6896
7322
|
this.notifyFrameHandlers(frame);
|
|
6897
7323
|
}
|
|
6898
|
-
catch
|
|
7324
|
+
catch {
|
|
6899
7325
|
this.updateState('error', 'Failed to parse frame from server');
|
|
6900
7326
|
}
|
|
6901
7327
|
};
|
|
@@ -6906,9 +7332,44 @@ class ConnectionManager {
|
|
|
6906
7332
|
reject(error);
|
|
6907
7333
|
}
|
|
6908
7334
|
};
|
|
6909
|
-
this.ws.onclose = () => {
|
|
7335
|
+
this.ws.onclose = (event) => {
|
|
6910
7336
|
this.stopPingInterval();
|
|
7337
|
+
this.clearTokenRefreshTimeout();
|
|
6911
7338
|
this.ws = null;
|
|
7339
|
+
if (this.reconnectForTokenRefresh) {
|
|
7340
|
+
this.reconnectForTokenRefresh = false;
|
|
7341
|
+
void this.connect().catch(() => {
|
|
7342
|
+
this.handleReconnect();
|
|
7343
|
+
});
|
|
7344
|
+
return;
|
|
7345
|
+
}
|
|
7346
|
+
// Parse close reason for error codes (e.g., "token-expired: Token has expired")
|
|
7347
|
+
const closeReason = event.reason || '';
|
|
7348
|
+
const errorCodeMatch = closeReason.match(/^([\w-]+):/);
|
|
7349
|
+
const errorCode = errorCodeMatch ? parseErrorCode(errorCodeMatch[1]) : null;
|
|
7350
|
+
// Check for auth errors that require token refresh
|
|
7351
|
+
if (event.code === 1008 || errorCode) {
|
|
7352
|
+
const isAuthError = errorCode
|
|
7353
|
+
? shouldRefreshToken(errorCode)
|
|
7354
|
+
: /expired|invalid|token/i.test(closeReason);
|
|
7355
|
+
if (isAuthError) {
|
|
7356
|
+
this.clearTokenState();
|
|
7357
|
+
// Try to reconnect immediately with a fresh token
|
|
7358
|
+
void this.connect().catch(() => {
|
|
7359
|
+
this.handleReconnect();
|
|
7360
|
+
});
|
|
7361
|
+
return;
|
|
7362
|
+
}
|
|
7363
|
+
// Check for rate limit errors
|
|
7364
|
+
const isRateLimit = errorCode === 'RATE_LIMIT_EXCEEDED' ||
|
|
7365
|
+
errorCode === 'CONNECTION_LIMIT_EXCEEDED' ||
|
|
7366
|
+
/rate.?limit|quota|limit.?exceeded/i.test(closeReason);
|
|
7367
|
+
if (isRateLimit) {
|
|
7368
|
+
this.updateState('error', `Rate limit exceeded: ${closeReason}`);
|
|
7369
|
+
// Don't auto-reconnect on rate limits, let user handle it
|
|
7370
|
+
return;
|
|
7371
|
+
}
|
|
7372
|
+
}
|
|
6912
7373
|
if (this.currentState !== 'disconnected') {
|
|
6913
7374
|
this.handleReconnect();
|
|
6914
7375
|
}
|
|
@@ -6924,6 +7385,8 @@ class ConnectionManager {
|
|
|
6924
7385
|
disconnect() {
|
|
6925
7386
|
this.clearReconnectTimeout();
|
|
6926
7387
|
this.stopPingInterval();
|
|
7388
|
+
this.clearTokenRefreshTimeout();
|
|
7389
|
+
this.reconnectForTokenRefresh = false;
|
|
6927
7390
|
this.updateState('disconnected');
|
|
6928
7391
|
if (this.ws) {
|
|
6929
7392
|
this.ws.close();
|
|
@@ -6941,7 +7404,7 @@ class ConnectionManager {
|
|
|
6941
7404
|
this.activeSubscriptions.add(subKey);
|
|
6942
7405
|
}
|
|
6943
7406
|
else {
|
|
6944
|
-
const alreadyQueued = this.subscriptionQueue.some((
|
|
7407
|
+
const alreadyQueued = this.subscriptionQueue.some((queuedSubscription) => this.makeSubKey(queuedSubscription) === subKey);
|
|
6945
7408
|
if (!alreadyQueued) {
|
|
6946
7409
|
this.subscriptionQueue.push(subscription);
|
|
6947
7410
|
}
|
|
@@ -6966,9 +7429,9 @@ class ConnectionManager {
|
|
|
6966
7429
|
}
|
|
6967
7430
|
flushSubscriptionQueue() {
|
|
6968
7431
|
while (this.subscriptionQueue.length > 0) {
|
|
6969
|
-
const
|
|
6970
|
-
if (
|
|
6971
|
-
this.subscribe(
|
|
7432
|
+
const subscription = this.subscriptionQueue.shift();
|
|
7433
|
+
if (subscription) {
|
|
7434
|
+
this.subscribe(subscription);
|
|
6972
7435
|
}
|
|
6973
7436
|
}
|
|
6974
7437
|
}
|
|
@@ -8688,6 +9151,7 @@ class HyperStack {
|
|
|
8688
9151
|
websocketUrl: url,
|
|
8689
9152
|
reconnectIntervals: options.reconnectIntervals,
|
|
8690
9153
|
maxReconnectAttempts: options.maxReconnectAttempts,
|
|
9154
|
+
auth: options.auth,
|
|
8691
9155
|
});
|
|
8692
9156
|
this.subscriptionRegistry = new SubscriptionRegistry(this.connection);
|
|
8693
9157
|
this.connection.onFrame((frame) => {
|
|
@@ -8721,6 +9185,7 @@ class HyperStack {
|
|
|
8721
9185
|
reconnectIntervals: options?.reconnectIntervals,
|
|
8722
9186
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
|
8723
9187
|
validateFrames: options?.validateFrames,
|
|
9188
|
+
auth: options?.auth,
|
|
8724
9189
|
};
|
|
8725
9190
|
const client = new HyperStack(url, internalOptions);
|
|
8726
9191
|
if (options?.autoReconnect !== false) {
|
|
@@ -8749,6 +9214,9 @@ class HyperStack {
|
|
|
8749
9214
|
onFrame(callback) {
|
|
8750
9215
|
return this.connection.onFrame(callback);
|
|
8751
9216
|
}
|
|
9217
|
+
onSocketIssue(callback) {
|
|
9218
|
+
return this.connection.onSocketIssue(callback);
|
|
9219
|
+
}
|
|
8752
9220
|
async connect() {
|
|
8753
9221
|
await this.connection.connect();
|
|
8754
9222
|
}
|
|
@@ -8974,6 +9442,7 @@ function HyperstackProvider({ children, fallback = null, ...config }) {
|
|
|
8974
9442
|
maxReconnectAttempts: config.maxReconnectAttempts,
|
|
8975
9443
|
maxEntriesPerView: config.maxEntriesPerView,
|
|
8976
9444
|
flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
9445
|
+
auth: config.auth,
|
|
8977
9446
|
}).then((client) => {
|
|
8978
9447
|
client.onConnectionStateChange((state, error) => {
|
|
8979
9448
|
adapter.setConnectionState(state, error);
|
|
@@ -8989,7 +9458,7 @@ function HyperstackProvider({ children, fallback = null, ...config }) {
|
|
|
8989
9458
|
});
|
|
8990
9459
|
connectingRef.current.set(cacheKey, connectionPromise);
|
|
8991
9460
|
return connectionPromise;
|
|
8992
|
-
}, [config.autoConnect, config.reconnectIntervals, config.maxReconnectAttempts, config.maxEntriesPerView, notifyClientChange]);
|
|
9461
|
+
}, [config.autoConnect, config.reconnectIntervals, config.maxReconnectAttempts, config.maxEntriesPerView, config.flushIntervalMs, config.auth, notifyClientChange]);
|
|
8993
9462
|
const getClient = useCallback((stack) => {
|
|
8994
9463
|
if (!stack) {
|
|
8995
9464
|
if (clientsRef.current.size === 1) {
|
|
@@ -9102,7 +9571,7 @@ function shallowArrayEqual(a, b) {
|
|
|
9102
9571
|
return true;
|
|
9103
9572
|
}
|
|
9104
9573
|
function useStateView(viewDef, client, key, options) {
|
|
9105
|
-
const [isLoading, setIsLoading] = useState(!options?.initialData);
|
|
9574
|
+
const [isLoading, setIsLoading] = useState(!options?.initialData && options?.withSnapshot !== false);
|
|
9106
9575
|
const [error, setError] = useState();
|
|
9107
9576
|
const clientRef = useRef(client);
|
|
9108
9577
|
clientRef.current = client;
|
|
@@ -9110,13 +9579,24 @@ function useStateView(viewDef, client, key, options) {
|
|
|
9110
9579
|
const keyString = key ? Object.values(key)[0] : undefined;
|
|
9111
9580
|
const enabled = options?.enabled !== false;
|
|
9112
9581
|
const schema = options?.schema;
|
|
9582
|
+
const withSnapshot = options?.withSnapshot;
|
|
9583
|
+
const after = options?.after;
|
|
9584
|
+
const snapshotLimit = options?.snapshotLimit;
|
|
9113
9585
|
useEffect(() => {
|
|
9114
9586
|
if (!enabled || !clientRef.current)
|
|
9115
9587
|
return undefined;
|
|
9116
9588
|
try {
|
|
9117
9589
|
const registry = clientRef.current.getSubscriptionRegistry();
|
|
9118
|
-
const unsubscribe = registry.subscribe({
|
|
9119
|
-
|
|
9590
|
+
const unsubscribe = registry.subscribe({
|
|
9591
|
+
view: viewDef.view,
|
|
9592
|
+
key: keyString,
|
|
9593
|
+
withSnapshot,
|
|
9594
|
+
after,
|
|
9595
|
+
snapshotLimit
|
|
9596
|
+
});
|
|
9597
|
+
if (withSnapshot !== false) {
|
|
9598
|
+
setIsLoading(true);
|
|
9599
|
+
}
|
|
9120
9600
|
return () => {
|
|
9121
9601
|
try {
|
|
9122
9602
|
unsubscribe();
|
|
@@ -9131,14 +9611,23 @@ function useStateView(viewDef, client, key, options) {
|
|
|
9131
9611
|
setIsLoading(false);
|
|
9132
9612
|
return undefined;
|
|
9133
9613
|
}
|
|
9134
|
-
}, [viewDef.view, keyString, enabled, client]);
|
|
9614
|
+
}, [viewDef.view, keyString, enabled, withSnapshot, after, snapshotLimit, client]);
|
|
9135
9615
|
const refresh = useCallback(() => {
|
|
9136
9616
|
if (!enabled || !clientRef.current)
|
|
9137
9617
|
return;
|
|
9138
9618
|
try {
|
|
9139
9619
|
const registry = clientRef.current.getSubscriptionRegistry();
|
|
9140
|
-
const unsubscribe = registry.subscribe({
|
|
9141
|
-
|
|
9620
|
+
const unsubscribe = registry.subscribe({
|
|
9621
|
+
view: viewDef.view,
|
|
9622
|
+
key: keyString,
|
|
9623
|
+
withSnapshot,
|
|
9624
|
+
after,
|
|
9625
|
+
snapshotLimit
|
|
9626
|
+
});
|
|
9627
|
+
const shouldLoad = withSnapshot ?? true;
|
|
9628
|
+
if (shouldLoad) {
|
|
9629
|
+
setIsLoading(true);
|
|
9630
|
+
}
|
|
9142
9631
|
setTimeout(() => {
|
|
9143
9632
|
try {
|
|
9144
9633
|
unsubscribe();
|
|
@@ -9152,7 +9641,7 @@ function useStateView(viewDef, client, key, options) {
|
|
|
9152
9641
|
setError(err instanceof Error ? err : new Error('Refresh failed'));
|
|
9153
9642
|
setIsLoading(false);
|
|
9154
9643
|
}
|
|
9155
|
-
}, [viewDef.view, keyString, enabled]);
|
|
9644
|
+
}, [viewDef.view, keyString, enabled, withSnapshot, after, snapshotLimit]);
|
|
9156
9645
|
const subscribe = useCallback((callback) => {
|
|
9157
9646
|
if (!clientRef.current)
|
|
9158
9647
|
return () => { };
|
|
@@ -9186,7 +9675,7 @@ function useStateView(viewDef, client, key, options) {
|
|
|
9186
9675
|
};
|
|
9187
9676
|
}
|
|
9188
9677
|
function useListView(viewDef, client, params, options) {
|
|
9189
|
-
const [isLoading, setIsLoading] = useState(!options?.initialData);
|
|
9678
|
+
const [isLoading, setIsLoading] = useState(!options?.initialData && params?.withSnapshot !== false);
|
|
9190
9679
|
const [error, setError] = useState();
|
|
9191
9680
|
const clientRef = useRef(client);
|
|
9192
9681
|
clientRef.current = client;
|
|
@@ -9199,6 +9688,9 @@ function useListView(viewDef, client, params, options) {
|
|
|
9199
9688
|
const filtersJson = params?.filters ? JSON.stringify(params.filters) : undefined;
|
|
9200
9689
|
const limit = params?.limit;
|
|
9201
9690
|
const schema = params?.schema;
|
|
9691
|
+
const withSnapshot = params?.withSnapshot;
|
|
9692
|
+
const after = params?.after;
|
|
9693
|
+
const snapshotLimit = params?.snapshotLimit;
|
|
9202
9694
|
useEffect(() => {
|
|
9203
9695
|
if (!enabled || !clientRef.current)
|
|
9204
9696
|
return undefined;
|
|
@@ -9209,9 +9701,14 @@ function useListView(viewDef, client, params, options) {
|
|
|
9209
9701
|
key,
|
|
9210
9702
|
filters: params?.filters,
|
|
9211
9703
|
take,
|
|
9212
|
-
skip
|
|
9704
|
+
skip,
|
|
9705
|
+
withSnapshot,
|
|
9706
|
+
after,
|
|
9707
|
+
snapshotLimit
|
|
9213
9708
|
});
|
|
9214
|
-
|
|
9709
|
+
if (withSnapshot !== false) {
|
|
9710
|
+
setIsLoading(true);
|
|
9711
|
+
}
|
|
9215
9712
|
return () => {
|
|
9216
9713
|
try {
|
|
9217
9714
|
unsubscribe();
|
|
@@ -9226,7 +9723,7 @@ function useListView(viewDef, client, params, options) {
|
|
|
9226
9723
|
setIsLoading(false);
|
|
9227
9724
|
return undefined;
|
|
9228
9725
|
}
|
|
9229
|
-
}, [viewDef.view, enabled, key, filtersJson, take, skip, client]);
|
|
9726
|
+
}, [viewDef.view, enabled, key, filtersJson, take, skip, withSnapshot, after, snapshotLimit, client]);
|
|
9230
9727
|
const refresh = useCallback(() => {
|
|
9231
9728
|
if (!enabled || !clientRef.current)
|
|
9232
9729
|
return;
|
|
@@ -9237,9 +9734,15 @@ function useListView(viewDef, client, params, options) {
|
|
|
9237
9734
|
key,
|
|
9238
9735
|
filters: params?.filters,
|
|
9239
9736
|
take,
|
|
9240
|
-
skip
|
|
9737
|
+
skip,
|
|
9738
|
+
withSnapshot,
|
|
9739
|
+
after,
|
|
9740
|
+
snapshotLimit
|
|
9241
9741
|
});
|
|
9242
|
-
|
|
9742
|
+
const shouldLoad = withSnapshot ?? true;
|
|
9743
|
+
if (shouldLoad) {
|
|
9744
|
+
setIsLoading(true);
|
|
9745
|
+
}
|
|
9243
9746
|
setTimeout(() => {
|
|
9244
9747
|
try {
|
|
9245
9748
|
unsubscribe();
|
|
@@ -9253,7 +9756,7 @@ function useListView(viewDef, client, params, options) {
|
|
|
9253
9756
|
setError(err instanceof Error ? err : new Error('Refresh failed'));
|
|
9254
9757
|
setIsLoading(false);
|
|
9255
9758
|
}
|
|
9256
|
-
}, [viewDef.view, enabled, key, filtersJson, take, skip]);
|
|
9759
|
+
}, [viewDef.view, enabled, key, filtersJson, take, skip, withSnapshot, after, snapshotLimit]);
|
|
9257
9760
|
const subscribe = useCallback((callback) => {
|
|
9258
9761
|
if (!clientRef.current)
|
|
9259
9762
|
return () => { };
|