Skip to content

Commit cef4a78

Browse files
committed
support for session enabled entities
1 parent 384b247 commit cef4a78

28 files changed

Lines changed: 724 additions & 547 deletions

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ For visual walkthroughs, screenshots, and detailed guides of these features, vis
2020

2121
---
2222

23+
## Current Limitations
24+
25+
While Bussin supports most Azure Service Bus operations directly in the browser, there are a few limitations to keep in mind:
26+
27+
* **Batch Send on Session-Enabled Entities**: Batch sending does not currently support session-enabled queues or topic subscriptions (which require a Session ID for each message).
28+
* **Partitioned & Session-Enabled Entities**: Certain administrative actions (such as the portal-based fast batch delete/purge API) are not supported by the Service Bus broker on partitioned or session-enabled entities. Bussin automatically falls back to compatible data-plane receiver flows (such as parallel receivers or sequential session loops) in these cases to complete the operation safely.
29+
30+
---
31+
32+
2333
* **Direct Client-Side Architecture**: Your data never leaves your browser. Bussin communicates directly with Azure APIs.
2434
* **Zero Installation**: Access the tool online at [https://bussin.dev](https://bussin.dev/) or install it as a PWA.
2535
* **Entra ID Integration**: Authenticate securely using your existing Azure identity and Role-Based Access Control (RBAC) roles.

client-js/serviceBusApi.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ import {
1313
complete,
1414
abandon,
1515
deadLetter,
16-
getMessageSessions,
17-
getSessionState,
18-
setSessionState
16+
getMessageSessions
1917
} from './src/peekOperations.js';
2018

2119
import {
@@ -76,8 +74,6 @@ if (typeof window !== 'undefined') {
7674
receiveAndLockQueueMessage,
7775
receiveAndLockSubscriptionMessage,
7876
getMessageSessions,
79-
getSessionState,
80-
setSessionState,
8177

8278
// Settlement operations (stateless - take LockedMessage[])
8379
complete,
@@ -136,8 +132,6 @@ export {
136132
receiveAndLockQueueMessage,
137133
receiveAndLockSubscriptionMessage,
138134
getMessageSessions,
139-
getSessionState,
140-
setSessionState,
141135

142136
// Settlement operations (stateless - take LockedMessage[])
143137
complete,

client-js/src/connection.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import rhea from 'rhea';
77
import type { Connection } from 'rhea';
88
import type { CBSAuthResult } from './types.js';
9+
import { formatAmqpError } from './types.js';
910

1011
import { GlobalMockBroker } from './mockBroker.js';
1112

@@ -90,12 +91,12 @@ export class ServiceBusConnection {
9091

9192
this.connection.on('connection_error', (context: any) => {
9293
console.error(`[ServiceBusConnection] Connection ERROR:`, context.error);
93-
reject(new Error(context.error ? context.error.toString() : 'Connection error'));
94+
reject(new Error(context.error ? formatAmqpError(context.error) : 'Connection error'));
9495
});
9596

9697
this.connection.on('disconnected', (context: any) => {
9798
if (context.error && !this.cbsAuthenticated) {
98-
reject(new Error(context.error.toString()));
99+
reject(new Error(formatAmqpError(context.error)));
99100
}
100101
});
101102
});

client-js/src/managementClient.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import rhea from 'rhea';
77
import type { ServiceBusConnection } from './connection.js';
88
import type { Sender, Receiver } from 'rhea';
9+
import { parseServiceBusMessage } from './messageParser.js';
10+
import type { LockedMessage } from './types.js';
11+
import { formatAmqpError } from './types.js';
912

1013
/**
1114
* Management Client - for management operations like true peek
@@ -17,11 +20,13 @@ export class ManagementClient {
1720
private sender: Sender | null = null;
1821
private receiver: Receiver | null = null;
1922
private replyTo: string | null = null;
23+
private readonly amqpSession?: any;
2024

21-
constructor(connection: ServiceBusConnection, entityPath: string) {
25+
constructor(connection: ServiceBusConnection, entityPath: string, amqpSession?: any) {
2226
this.connection = connection;
2327
this.entityPath = entityPath;
2428
this.managementAddress = `${entityPath}/$management`;
29+
this.amqpSession = amqpSession;
2530
}
2631

2732
/**
@@ -36,11 +41,13 @@ export class ManagementClient {
3641
// Create unique reply-to address
3742
this.replyTo = `mgmt-reply-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3843

39-
this.sender = this.connection.connection!.open_sender({
44+
const sessionOrConn = this.amqpSession || this.connection.connection!;
45+
46+
this.sender = sessionOrConn.open_sender({
4047
target: { address: this.managementAddress }
4148
});
4249

43-
this.receiver = this.connection.connection!.open_receiver({
50+
this.receiver = sessionOrConn.open_receiver({
4451
source: { address: this.managementAddress },
4552
target: { address: this.replyTo }
4653
});
@@ -65,11 +72,11 @@ export class ManagementClient {
6572
});
6673

6774
this.sender.on('sender_error', (context: any) => {
68-
reject(new Error(context.sender.error ? context.sender.error.toString() : 'Sender error'));
75+
reject(new Error(context.sender.error ? formatAmqpError(context.sender.error) : 'Sender error'));
6976
});
7077

7178
this.receiver.on('receiver_error', (context: any) => {
72-
reject(new Error(context.receiver.error ? context.receiver.error.toString() : 'Receiver error'));
79+
reject(new Error(context.receiver.error ? formatAmqpError(context.receiver.error) : 'Receiver error'));
7380
});
7481

7582
setTimeout(() => reject(new Error('Management client open timeout')), 10000);
@@ -224,7 +231,7 @@ export class ManagementClient {
224231
* Lock messages by sequence numbers (peek-lock mode)
225232
* Returns lock tokens that can be used to complete/abandon/dead-letter
226233
*/
227-
async lockBySequenceNumbers(sequenceNumbers: number[]): Promise<{ sequenceNumber: number; lockToken: string }[]> {
234+
async lockBySequenceNumbers(sequenceNumbers: number[]): Promise<LockedMessage[]> {
228235
if (!this.sender || !this.receiver || !this.replyTo) {
229236
throw new Error('Management client not opened');
230237
}
@@ -242,18 +249,23 @@ export class ManagementClient {
242249
if (statusCode === 200) {
243250
// Response body contains messages with lock tokens
244251
const body = context.message.body;
245-
const results: { sequenceNumber: number; lockToken: string }[] = [];
252+
const results: LockedMessage[] = [];
246253

247254
if (body && body.messages) {
248255
const msgArray = Array.isArray(body.messages) ? body.messages : [body.messages];
249256
for (let i = 0; i < msgArray.length; i++) {
250257
const item = msgArray[i];
251258
// Lock token is in the 'lock-token' field as a UUID
252259
if (item['lock-token']) {
253-
results.push({
254-
sequenceNumber: sequenceNumbers[i],
255-
lockToken: uuidFromBuffer(item['lock-token'])
256-
});
260+
const lockToken = uuidFromBuffer(item['lock-token']);
261+
const rawMessage = item['message'];
262+
let parsed: any = {};
263+
if (rawMessage) {
264+
const decoded = rhea.message.decode(rawMessage);
265+
parsed = parseServiceBusMessage(decoded);
266+
}
267+
parsed.lockToken = lockToken;
268+
results.push(parsed as LockedMessage);
257269
}
258270
}
259271
}
@@ -294,7 +306,6 @@ export class ManagementClient {
294306
}, 10000);
295307
});
296308
}
297-
298309
/**
299310
* Cancel scheduled messages by sequence numbers
300311
*/
@@ -774,7 +785,7 @@ export class ManagementClient {
774785
/**
775786
* Get session state
776787
*/
777-
async getSessionState(sessionId: string): Promise<string> {
788+
async getSessionState(sessionId: string, associatedLinkName?: string): Promise<string> {
778789
if (!this.sender || !this.receiver || !this.replyTo) {
779790
throw new Error('Management client not opened');
780791
}
@@ -825,7 +836,8 @@ export class ManagementClient {
825836
body: messageBody,
826837
reply_to: replyTo,
827838
application_properties: {
828-
operation: 'com.microsoft:get-session-state'
839+
operation: 'com.microsoft:get-session-state',
840+
...(associatedLinkName ? { 'associated-link-name': associatedLinkName } : {})
829841
},
830842
message_id: messageId
831843
};
@@ -839,10 +851,7 @@ export class ManagementClient {
839851
});
840852
}
841853

842-
/**
843-
* Set session state
844-
*/
845-
async setSessionState(sessionId: string, state: string | null): Promise<void> {
854+
async setSessionState(sessionId: string, state: string | null, associatedLinkName?: string): Promise<void> {
846855
if (!this.sender || !this.receiver || !this.replyTo) {
847856
throw new Error('Management client not opened');
848857
}
@@ -881,7 +890,8 @@ export class ManagementClient {
881890
body: messageBody,
882891
reply_to: replyTo,
883892
application_properties: {
884-
operation: 'com.microsoft:set-session-state'
893+
operation: 'com.microsoft:set-session-state',
894+
...(associatedLinkName ? { 'associated-link-name': associatedLinkName } : {})
885895
},
886896
message_id: messageId
887897
};

client-js/src/messageParser.ts

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,117 @@ export function parseServiceBusMessage(amqpMessage: any): ServiceBusMessage {
4343
}
4444

4545
// Get message annotations
46-
const annotations = amqpMessage.message_annotations || {};
46+
const annotations = amqpMessage.message_annotations || amqpMessage.messageAnnotations || amqpMessage.delivery_annotations || amqpMessage.deliveryAnnotations || {};
47+
48+
const getAnnotationValue = (key: string): any => {
49+
if (!annotations) return undefined;
50+
if (annotations[key] !== undefined) return annotations[key];
51+
52+
// Handle Symbol keys
53+
const symbols = Object.getOwnPropertySymbols(annotations);
54+
for (const sym of symbols) {
55+
const symStr = sym.toString();
56+
if (symStr === `Symbol(${key})` || sym.description === key || symStr.includes(key)) {
57+
return annotations[sym];
58+
}
59+
}
60+
61+
// Check standard keys or Map keys as string
62+
for (const k of Object.keys(annotations)) {
63+
if (k === key || k.toString() === key) {
64+
return annotations[k];
65+
}
66+
}
67+
68+
// If annotations is a Map
69+
try {
70+
if (typeof annotations.get === 'function') {
71+
const val = annotations.get(key);
72+
if (val !== undefined) return val;
73+
for (const [k, v] of annotations.entries()) {
74+
if (k === key || (k && k.toString() === key)) {
75+
return v;
76+
}
77+
}
78+
}
79+
} catch {}
80+
81+
return undefined;
82+
};
4783

48-
// Extract enqueued time - could be Date or timestamp
49-
let enqueuedTime = annotations['x-opt-enqueued-time'];
50-
if (enqueuedTime && typeof enqueuedTime === 'number') {
51-
enqueuedTime = new Date(enqueuedTime);
52-
}
84+
// Helper to robustly decode AMQP long types (number, bigint, high/low object, buffer)
85+
const decodeAmqpLong = (val: any): number | undefined => {
86+
if (val === undefined || val === null) return undefined;
87+
if (typeof val === 'number') return val;
88+
if (typeof val === 'bigint') return Number(val);
89+
if (typeof val === 'object') {
90+
if ('high' in val && 'low' in val) {
91+
return val.high * 4294967296 + (val.low >>> 0);
92+
}
93+
if (val instanceof Uint8Array || val.type === 'Buffer' || Array.isArray(val.data)) {
94+
const buf = val.data ? new Uint8Array(val.data) : val;
95+
let num = BigInt(0);
96+
for (let i = 0; i < buf.length; i++) {
97+
num = (num << BigInt(8)) + BigInt(buf[i]);
98+
}
99+
return Number(num);
100+
}
101+
}
102+
const parsed = Number(val);
103+
return isNaN(parsed) ? undefined : parsed;
104+
};
105+
106+
// Helper to robustly decode AMQP timestamp/datetime types to ISO string
107+
const parseAmqpDate = (val: any): string | undefined => {
108+
if (val === undefined || val === null) return undefined;
109+
110+
let d: Date | undefined;
111+
if (val instanceof Date) {
112+
d = val;
113+
} else {
114+
// If it's a number, bigint, or long object/buffer, decode to timestamp
115+
const ms = decodeAmqpLong(val);
116+
if (ms !== undefined && !isNaN(ms)) {
117+
d = new Date(ms);
118+
} else if (typeof val === 'string') {
119+
d = new Date(val);
120+
}
121+
}
122+
123+
if (d && !isNaN(d.getTime())) {
124+
try {
125+
const year = d.getUTCFullYear();
126+
if (year > 9999) {
127+
return "9999-12-31T23:59:59.999Z";
128+
}
129+
if (year < 1) {
130+
return "0001-01-01T00:00:00.000Z";
131+
}
132+
return d.toISOString();
133+
} catch {
134+
return undefined;
135+
}
136+
}
137+
138+
return undefined;
139+
};
140+
141+
// Extract enqueued time
142+
const enqueuedTime = parseAmqpDate(getAnnotationValue('x-opt-enqueued-time'));
53143

54144
// Extract scheduled enqueue time
55-
let scheduledEnqueueTime = annotations['x-opt-scheduled-enqueue-time'];
145+
let scheduledEnqueueTime = getAnnotationValue('x-opt-scheduled-enqueue-time');
56146
if (scheduledEnqueueTime instanceof Date) {
57147
scheduledEnqueueTime = scheduledEnqueueTime.getTime();
148+
} else {
149+
scheduledEnqueueTime = decodeAmqpLong(scheduledEnqueueTime);
58150
}
59151

152+
// Extract sequence number
153+
const sequenceNumber = decodeAmqpLong(getAnnotationValue('x-opt-sequence-number') ?? amqpMessage._sequenceNumber);
154+
60155
// Extract partition key
61-
const partitionKey = annotations['x-opt-partition-key'] ?? amqpMessage.group_id ?? props.group_id;
156+
const partitionKey = getAnnotationValue('x-opt-partition-key') ?? amqpMessage.group_id ?? props.group_id;
62157

63158
return {
64159
messageId: amqpMessage.message_id ?? props.message_id,
@@ -71,16 +166,17 @@ export function parseServiceBusMessage(amqpMessage: any): ServiceBusMessage {
71166
to: amqpMessage.to ?? props.to,
72167
deliveryCount: amqpMessage.delivery_count ?? amqpMessage.header?.delivery_count ?? 0,
73168
enqueuedTime: enqueuedTime,
74-
sequenceNumber: (annotations && annotations['x-opt-sequence-number']) ?? amqpMessage._sequenceNumber,
75-
lockedUntil: (annotations && annotations['x-opt-locked-until']),
169+
sequenceNumber: sequenceNumber,
170+
lockedUntil: parseAmqpDate(getAnnotationValue('x-opt-locked-until')),
76171
scheduledEnqueueTime: scheduledEnqueueTime,
77172
partitionKey: partitionKey,
173+
state: getAnnotationValue('x-opt-state'),
78174
applicationProperties: amqpMessage.application_properties || {},
79175
messageAnnotations: annotations,
80176
properties: props,
81177
ttl: ttl,
82-
expiryTime: amqpMessage.absolute_expiry_time ?? props.absolute_expiry_time,
83-
creationTime: amqpMessage.creation_time ?? props.creation_time
178+
expiryTime: parseAmqpDate(amqpMessage.absolute_expiry_time ?? props.absolute_expiry_time),
179+
creationTime: parseAmqpDate(amqpMessage.creation_time ?? props.creation_time)
84180
};
85181
}
86182

client-js/src/messageSender.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { ServiceBusConnection } from './connection.js';
77
import type { MessageProperties } from './types.js';
8+
import { formatAmqpError } from './types.js';
89
import type { Sender } from 'rhea';
910
import { message as rheaMessage } from 'rhea';
1011

@@ -39,7 +40,7 @@ export class MessageSender {
3940
});
4041

4142
this.sender.on('sender_error', (context: any) => {
42-
reject(new Error(context.sender.error ? context.sender.error.toString() : 'Sender error'));
43+
reject(new Error(context.sender.error ? formatAmqpError(context.sender.error) : 'Sender error'));
4344
});
4445

4546
setTimeout(() => reject(new Error('Sender open timeout')), 5000);

0 commit comments

Comments
 (0)