Skip to content

Commit c367914

Browse files
feat(hubspot,#85): add AP1 region, event bridges, typed config, status()
Vendor-doc audit fixes for HubSpot Conversations Chat Widget: - Region: HubSpotRegion now "na1" | "eu1" | "ap1" with the correct js-ap1.hs-scripts.com host (previously AP1 portals fell back to NA1 and loaded the wrong CDN). - Event bridges (7): on(event) now subscribes to conversationStarted, conversationClosed, userSelectedThread, contactAssociated, userInteractedWithWidget, widgetLoaded, widgetClosed, quickReplyButtonClick — same multi-listener pattern as onUnreadCountChange. - Typed config (7 fields on HubSpotLoadOptions): inlineEmbedSelector, enableWidgetCookieBanner, disableAttachment, disableInitialInputFocus, avoidInlineStyles, hideNewThreadLink, loadImmediately. Merged into hsConversationsSettings before boot. - New status() helper exposes widget.status() ({ loaded, pending }). - identify() error message and warn() copy no longer call HubSpot's identification token a "JWT" — vendor mints an opaque token, not a JWT. Bumps hubspot bundle budget 6KB → 7KB (raw 6928B; gzip ≈ 1/3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d0420d5 commit c367914

3 files changed

Lines changed: 244 additions & 5 deletions

File tree

scripts/bundle-budget.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const BUDGETS = [
1313
{ glob: /^providers\/chatwoot\.mjs$/, max: 10240, label: "provider:chatwoot" },
1414
{ glob: /^providers\/intercom\.mjs$/, max: 9216, label: "provider:intercom" },
1515
{ glob: /^providers\/helpscout\.mjs$/, max: 7168, label: "provider:helpscout" },
16+
{ glob: /^providers\/hubspot\.mjs$/, max: 7168, label: "provider:hubspot" },
1617
{ glob: /^providers\/.+\.mjs$/, max: 6144, label: "provider" },
1718
{ glob: /^index\.mjs$/, max: 4096, label: "core" },
1819
{ glob: /^facade\.mjs$/, max: 3072, label: "facade" },

src/providers/hubspot.ts

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface HubSpotConversations {
1818
close(): void
1919
remove(): void
2020
refresh(options?: { openToNewThread?: boolean }): void
21+
status?(): { loaded: boolean; pending: boolean }
2122
}
2223
clear(options?: { resetWidget?: boolean }): void
2324
on(event: string, listener: (payload: unknown) => void): void
@@ -45,12 +46,59 @@ const conversations = createQueue<HubSpotConversations>()
4546
const store = createIdentityStore()
4647
const lifecycle = createLifecycle()
4748
const unreadListeners = new Set<(count: number) => void>()
49+
type HubSpotEventName =
50+
| "conversationStarted"
51+
| "conversationClosed"
52+
| "userSelectedThread"
53+
| "contactAssociated"
54+
| "userInteractedWithWidget"
55+
| "widgetLoaded"
56+
| "widgetClosed"
57+
| "quickReplyButtonClick"
58+
const eventListeners = new Map<HubSpotEventName, Set<(payload: unknown) => void>>()
59+
const HUBSPOT_EVENTS: readonly HubSpotEventName[] = [
60+
"conversationStarted",
61+
"conversationClosed",
62+
"userSelectedThread",
63+
"contactAssociated",
64+
"userInteractedWithWidget",
65+
"widgetLoaded",
66+
"widgetClosed",
67+
"quickReplyButtonClick",
68+
]
4869
let readyPromise: Promise<void> | undefined
4970
let readyResolve: (() => void) | undefined
5071

72+
const TYPED_SETTINGS_KEYS = [
73+
"inlineEmbedSelector",
74+
"enableWidgetCookieBanner",
75+
"disableAttachment",
76+
"disableInitialInputFocus",
77+
"avoidInlineStyles",
78+
"hideNewThreadLink",
79+
"loadImmediately",
80+
] as const
81+
82+
export type HubSpotRegion = "na1" | "eu1" | "ap1"
83+
export type HubSpotCookieBanner = boolean | "ON_WIDGET_LOAD" | "ON_EXIT_INTENT"
84+
5185
export interface HubSpotLoadOptions extends LoadOptions {
5286
portalId: string
53-
region?: "na1" | "eu1"
87+
region?: HubSpotRegion
88+
/** Inline-embedded chat: CSS selector for the host element. */
89+
inlineEmbedSelector?: string
90+
/** GDPR cookie banner mode. */
91+
enableWidgetCookieBanner?: HubSpotCookieBanner
92+
/** Disable attachment uploads. */
93+
disableAttachment?: boolean
94+
/** Disable focusing the composer on widget open. */
95+
disableInitialInputFocus?: boolean
96+
/** Avoid inline styles (helps strict CSP setups). */
97+
avoidInlineStyles?: boolean
98+
/** Hide the "Start a new conversation" link. */
99+
hideNewThreadLink?: boolean
100+
/** When true, HubSpot auto-loads the widget on script ready (default: true). Wrapper sets false unless overridden. */
101+
loadImmediately?: boolean
54102
}
55103

56104
function lowercaseKeys<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
@@ -88,7 +136,12 @@ export async function load(options: HubSpotLoadOptions): Promise<void> {
88136
lifecycle.setConfigHash(h)
89137
await waitForDefer(options.defer ?? "immediate")
90138

91-
w().hsConversationsSettings = { loadImmediately: false }
139+
const settings: Record<string, unknown> = { loadImmediately: false }
140+
for (const key of TYPED_SETTINGS_KEYS) {
141+
const v = options[key]
142+
if (v !== undefined) settings[key] = v
143+
}
144+
w().hsConversationsSettings = settings
92145

93146
readyPromise = new Promise((r) => {
94147
readyResolve = r
@@ -102,11 +155,23 @@ export async function load(options: HubSpotLoadOptions): Promise<void> {
102155
const count = (payload as { unreadCount?: number } | undefined)?.unreadCount ?? 0
103156
for (const l of unreadListeners) l(count)
104157
})
158+
for (const evt of HUBSPOT_EVENTS) {
159+
api.on(evt, (payload: unknown) => {
160+
const set = eventListeners.get(evt)
161+
if (!set) return
162+
for (const l of set) l(payload)
163+
})
164+
}
105165
readyResolve?.()
106166
}
107167
})
108168

109-
const host = options.region === "eu1" ? "js-eu1.hs-scripts.com" : "js.hs-scripts.com"
169+
const host =
170+
options.region === "eu1"
171+
? "js-eu1.hs-scripts.com"
172+
: options.region === "ap1"
173+
? "js-ap1.hs-scripts.com"
174+
: "js.hs-scripts.com"
110175
try {
111176
await injectScript({
112177
id: "ahize-hubspot",
@@ -125,11 +190,15 @@ export async function load(options: HubSpotLoadOptions): Promise<void> {
125190
export function identify(identity: Identity): Promise<void> {
126191
if (!isBrowser()) return Promise.resolve()
127192
if (identity.verification && identity.verification.kind !== "jwt") {
128-
return Promise.reject(new Error("HubSpot requires JWT verification (kind: 'jwt')"))
193+
return Promise.reject(
194+
new Error(
195+
"HubSpot requires an identification token (modeled as kind: 'jwt'); HubSpot's token is opaque, not a true JWT.",
196+
),
197+
)
129198
}
130199
if (identity.email && !identity.verification) {
131200
console.warn(
132-
"[ahize/hubspot] identify() called with email but no JWT — HubSpot treats the session as anonymous until identificationToken is provided.",
201+
"[ahize/hubspot] identify() called with email but no identificationToken — HubSpot treats the session as anonymous until a token is provided.",
133202
)
134203
}
135204
store.identify(identity)
@@ -214,6 +283,21 @@ export function onUnreadCountChange(listener: (count: number) => void): () => vo
214283
return () => unreadListeners.delete(listener)
215284
}
216285

286+
export function on(event: HubSpotEventName, listener: (payload: unknown) => void): () => void {
287+
let set = eventListeners.get(event)
288+
if (!set) {
289+
set = new Set()
290+
eventListeners.set(event, set)
291+
}
292+
set.add(listener)
293+
return () => set?.delete(listener)
294+
}
295+
296+
export function status(): { loaded: boolean; pending: boolean } | undefined {
297+
if (!isBrowser()) return undefined
298+
return w().HubSpotConversations?.widget.status?.()
299+
}
300+
217301
export async function destroy(): Promise<void> {
218302
if (!isBrowser()) return
219303
await shutdown().catch(() => undefined)
@@ -226,6 +310,7 @@ export async function destroy(): Promise<void> {
226310
conversations.reset()
227311
store.reset()
228312
unreadListeners.clear()
313+
eventListeners.clear()
229314
readyPromise = undefined
230315
readyResolve = undefined
231316
lifecycle.clearConfigHash()

test/hubspot.browser.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// @vitest-environment jsdom
2+
import { beforeEach, describe, expect, it } from "vitest"
3+
4+
interface FakeApi {
5+
on: Map<string, Array<(p: unknown) => void>>
6+
widget: {
7+
load: () => void
8+
open: () => void
9+
close: () => void
10+
remove: () => void
11+
refresh: () => void
12+
status: () => { loaded: boolean; pending: boolean }
13+
}
14+
clear: () => void
15+
fire: (event: string, payload?: unknown) => void
16+
}
17+
18+
function fakeHubSpot(): FakeApi {
19+
const handlers = new Map<string, Array<(p: unknown) => void>>()
20+
return {
21+
on: handlers,
22+
widget: {
23+
load: () => {},
24+
open: () => {},
25+
close: () => {},
26+
remove: () => {},
27+
refresh: () => {},
28+
status: () => ({ loaded: true, pending: false }),
29+
},
30+
clear: () => {},
31+
fire(event, payload) {
32+
for (const h of handlers.get(event) ?? []) h(payload)
33+
},
34+
}
35+
}
36+
37+
async function bootHubSpot(
38+
options?: Partial<import("../src/providers/hubspot.ts").HubSpotLoadOptions>,
39+
): Promise<{
40+
hubspot: typeof import("../src/providers/hubspot.ts")
41+
fake: FakeApi
42+
}> {
43+
const hubspot = await import("../src/providers/hubspot.ts")
44+
const fake = fakeHubSpot()
45+
const apiShim = {
46+
widget: fake.widget,
47+
clear: fake.clear,
48+
on: (event: string, listener: (p: unknown) => void) => {
49+
const arr = fake.on.get(event) ?? []
50+
arr.push(listener)
51+
fake.on.set(event, arr)
52+
},
53+
off: () => {},
54+
}
55+
// biome-ignore lint/suspicious/noExplicitAny: test shim
56+
;(globalThis as any).HubSpotConversations = apiShim
57+
const loadPromise = hubspot.load({ portalId: "12345", ...options })
58+
await new Promise((r) => setTimeout(r, 0))
59+
const script = document.getElementById("ahize-hubspot") as HTMLScriptElement
60+
expect(script).toBeTruthy()
61+
script.dispatchEvent(new Event("load"))
62+
// biome-ignore lint/suspicious/noExplicitAny: test shim
63+
for (const cb of (globalThis as any).hsConversationsOnReady ?? []) cb()
64+
await loadPromise
65+
return { hubspot, fake }
66+
}
67+
68+
describe("hubspot (browser) — vendor-doc audit fixes (#85)", () => {
69+
beforeEach(() => {
70+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
71+
delete (globalThis as any).HubSpotConversations
72+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
73+
delete (globalThis as any).hsConversationsSettings
74+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
75+
delete (globalThis as any).hsConversationsOnReady
76+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
77+
delete (globalThis as any)._hsq
78+
const scripts = document.querySelectorAll("script")
79+
for (let i = 0; i < scripts.length; i++) {
80+
;(scripts[i] as { remove(): void } | undefined)?.remove()
81+
}
82+
})
83+
84+
it("ap1 region uses js-ap1.hs-scripts.com", async () => {
85+
const { hubspot } = await bootHubSpot({ region: "ap1" })
86+
const script = document.getElementById("ahize-hubspot") as HTMLScriptElement
87+
expect(script.src).toContain("js-ap1.hs-scripts.com")
88+
await hubspot.destroy()
89+
})
90+
91+
it("typed settings populate hsConversationsSettings", async () => {
92+
const { hubspot } = await bootHubSpot({
93+
inlineEmbedSelector: "#chat-here",
94+
enableWidgetCookieBanner: "ON_EXIT_INTENT",
95+
disableAttachment: true,
96+
disableInitialInputFocus: true,
97+
avoidInlineStyles: true,
98+
hideNewThreadLink: true,
99+
loadImmediately: true,
100+
})
101+
// biome-ignore lint/suspicious/noExplicitAny: test shim
102+
const settings = (globalThis as any).hsConversationsSettings as Record<string, unknown>
103+
expect(settings).toMatchObject({
104+
inlineEmbedSelector: "#chat-here",
105+
enableWidgetCookieBanner: "ON_EXIT_INTENT",
106+
disableAttachment: true,
107+
disableInitialInputFocus: true,
108+
avoidInlineStyles: true,
109+
hideNewThreadLink: true,
110+
loadImmediately: true,
111+
})
112+
await hubspot.destroy()
113+
})
114+
115+
it("on(event) bridges all 7 documented widget events", async () => {
116+
const { hubspot, fake } = await bootHubSpot()
117+
const seen: Record<string, unknown[]> = {}
118+
const events = [
119+
"conversationStarted",
120+
"conversationClosed",
121+
"userSelectedThread",
122+
"contactAssociated",
123+
"userInteractedWithWidget",
124+
"widgetLoaded",
125+
"widgetClosed",
126+
"quickReplyButtonClick",
127+
] as const
128+
for (const e of events) {
129+
seen[e] = []
130+
hubspot.on(e, (p) => seen[e]?.push(p))
131+
}
132+
for (const e of events) fake.fire(e, { event: e })
133+
for (const e of events) expect(seen[e]).toEqual([{ event: e }])
134+
await hubspot.destroy()
135+
})
136+
137+
it("status() returns the widget's loaded/pending state", async () => {
138+
const { hubspot } = await bootHubSpot()
139+
expect(hubspot.status()).toEqual({ loaded: true, pending: false })
140+
await hubspot.destroy()
141+
})
142+
143+
it("identify() rejects non-jwt verification with token-not-jwt error message", async () => {
144+
const { hubspot } = await bootHubSpot()
145+
await expect(
146+
hubspot.identify({
147+
id: "u1",
148+
verification: { kind: "hmac", hash: "x" },
149+
}),
150+
).rejects.toThrow(/identification token/)
151+
await hubspot.destroy()
152+
})
153+
})

0 commit comments

Comments
 (0)