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