@pmeweb/plugin-chat 1.2.1 → 1.3.1

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/src/widget.js CHANGED
@@ -37,8 +37,8 @@
37
37
  // a visitor's question drawn from it (demoQuestion) and a canned answer, with the composer disabled
38
38
  // and a hint saying why. The client sees their face and colours in the « Contenu » preview.
39
39
  import css from './widget.css?inline'
40
- import { isCanonical } from '@pmeweb/loader'
41
- import { addScript as loadTurnstile, resetToken, tokenReady } from '@pmeweb/turnstile'
40
+ import { isCanonical, onApproach } from '@pmeweb/loader'
41
+ import { addScript as loadTurnstile, tokenReady } from '@pmeweb/turnstile'
42
42
  import { CLOSE, MAX_TEXT } from './protocol.js'
43
43
  import { fill, langOf, stringsFor } from './strings.js'
44
44
 
@@ -49,6 +49,12 @@ export const GOAL = 'chat-lead'
49
49
  export const MAX_PHOTOS = 3
50
50
  export const PHOTO_EDGE = 1600
51
51
  export const PHOTO_QUALITY = 0.85
52
+ /** How long after the page has painted (and the widget was imported) Turnstile starts, unasked. */
53
+ export const WARM_MS = 3000
54
+ /** A first start slower than this shows « Un instant… » under the typing dots. */
55
+ export const SLOW_START_MS = 8000
56
+ /** How close to the bottom of the thread (px) still counts as « at the bottom ». */
57
+ export const PIN_SLACK = 40
52
58
  const TYPING_SEND_MS = 3000
53
59
  const TYPING_SHOW_MS = 8000
54
60
 
@@ -90,14 +96,58 @@ function el(doc, tag, attrs = {}, text) {
90
96
  return e
91
97
  }
92
98
 
93
- const defaultTurnstile = (win) => ({
94
- load: () => loadTurnstile(win.document),
95
- async token(form) {
96
- await tokenReady(form, { win })
97
- return form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
98
- },
99
- reset: (form) => resetToken(form, { win }),
100
- })
99
+ // TURNSTILE (operator, 2026-11-13). Loaded only when the panel opened, the challenge had no time to
100
+ // observe the visitor and often showed its « Vérifiez que vous êtes humain » box. So it now starts
101
+ // early, but never in a test run: the trigger imports this file on the visitor's first sign of life
102
+ // on the page (a scroll, a pointer moving, a press, a key), and mountChat then starts api.js through
103
+ // @pmeweb/loader onApproach on the chat root (fixed on screen): once the page has painted (`load`,
104
+ // first contentful paint, LCP, and this file's own import, whichever is latest) plus WARM_MS, or at
105
+ // once on a press or a focus in the chat, or when the panel opens. Lighthouse never interacts, so it
106
+ // never imports this file and never fetches api.js; a visitor who reads for a few seconds before
107
+ // opening the panel usually finds the token minted.
108
+ //
109
+ // THE WIDGETS RENDER HIDDEN, in the closed panel (a display:none iframe still runs: measured with
110
+ // Cloudflare's test keys, a token in ~1.9 s). Each sits in a row that is COLLAPSED until Cloudflare
111
+ // itself asks for the visitor: its `before-interactive` and `error` callbacks open the row, its
112
+ // success callback collapses it again (so « Succès ! » goes away), and a later expiry that needs a
113
+ // click opens it again. The rule « never hide the box with a guessed rule » holds: the row follows
114
+ // Cloudflare's callbacks, never the iframe's size. Implicit rendering only scans the page once, when
115
+ // api.js runs, so a widget added after a contact form already loaded api.js is rendered here.
116
+ //
117
+ // THE TOKEN: Turnstile refreshes an unused one before its 300 s expiry (refresh-expired: auto); after
118
+ // each post the widget is reset (a token is single-use), only while its row is not asking: a reset
119
+ // would wipe the box the visitor is solving. `reset(el)` names our widget, never every widget on the
120
+ // page (@pmeweb/turnstile rule 4). Its own resetToken is not used: it keys on the box's height, and
121
+ // a solved box keeps its height inside our collapsed row.
122
+ const defaultTurnstile = (win, widgets) => {
123
+ let polling = false
124
+ const render = () => {
125
+ const ts = win.turnstile
126
+ if (!ts?.render) return false
127
+ for (const w of widgets) if (!w.childElementCount) try { ts.render(w) } catch { /* left to tokenReady's timeout */ }
128
+ return true
129
+ }
130
+ const load = () => {
131
+ loadTurnstile(win.document)
132
+ if (polling || render()) return
133
+ polling = true
134
+ let n = 0
135
+ const tick = () => { if (!render() && ++n < 600) win.setTimeout(tick, 100) }
136
+ win.setTimeout(tick, 100)
137
+ }
138
+ for (const w of widgets) { const f = w.closest('form'); if (f) f.pmwTurnstileLoad = load }
139
+ return {
140
+ load,
141
+ async token(form) {
142
+ await tokenReady(form, { win })
143
+ return form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
144
+ },
145
+ reset(form) {
146
+ const w = form.querySelector('.cf-turnstile')
147
+ if (w && !w.closest('[data-ask]')) win.turnstile?.reset(w)
148
+ },
149
+ }
150
+ }
101
151
 
102
152
  export function mountChat(root, deps = {}) {
103
153
  if (root.pmwChat) return root.pmwChat
@@ -105,7 +155,8 @@ export function mountChat(root, deps = {}) {
105
155
  const doc = win.document
106
156
  const WS = deps.WebSocket ?? win.WebSocket
107
157
  const fetchImpl = deps.fetch ?? ((...a) => win.fetch(...a))
108
- const turnstile = deps.turnstile ?? defaultTurnstile(win)
158
+ const widgets = []
159
+ let turnstile = deps.turnstile ?? null
109
160
  const uuid = deps.uuid ?? (() => win.crypto.randomUUID())
110
161
  const shrink = deps.shrink ?? ((file) => shrinkPhoto(win, file))
111
162
  // A data: URL, not a blob: one: site CSPs allow `img-src data:` (boilerplate worker), not blob:.
@@ -164,19 +215,37 @@ export function mountChat(root, deps = {}) {
164
215
  const state = el(doc, 'p', { class: 'pmw-chat__state', role: 'status' })
165
216
  const announcer = el(doc, 'p', { class: 'pmw-chat__sr', 'aria-live': 'polite' })
166
217
 
167
- const widget = (form) => el(doc, 'div', {
168
- class: 'cf-turnstile pmw-turnstile', 'data-sitekey': cfg.siteKey ?? '', 'data-action': 'chat', 'data-theme': 'light',
169
- 'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
170
- })
218
+ // A widget in its row (see « TURNSTILE » above). Implicit rendering reads callbacks as global
219
+ // function names, one set per widget (`c` the composer, `f` the contact form).
220
+ const widget = (key) => {
221
+ const row = el(doc, 'div', { class: 'pmw-chat__check' })
222
+ const inner = el(doc, 'div', { class: 'pmw-chat__check-in' })
223
+ const cb = `pmwChatTurnstile${key}`
224
+ const ask = (on) => () => { row.toggleAttribute('data-ask', on); follow() }
225
+ win[`${cb}Ask`] = ask(true)
226
+ win[`${cb}Error`] = ask(true)
227
+ win[`${cb}Done`] = ask(false)
228
+ const w = el(doc, 'div', {
229
+ class: 'cf-turnstile pmw-turnstile', 'data-sitekey': cfg.siteKey ?? '', 'data-action': 'chat', 'data-theme': 'light',
230
+ 'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
231
+ 'data-callback': `${cb}Done`, 'data-before-interactive-callback': `${cb}Ask`, 'data-error-callback': `${cb}Error`,
232
+ })
233
+ widgets.push(w)
234
+ inner.append(w)
235
+ row.append(inner)
236
+ return row
237
+ }
171
238
  const composer = el(doc, 'form', { class: 'pmw-chat__composer', novalidate: true })
172
239
  const input = el(doc, 'textarea', { id: 'pmw-chat-input', name: 'message', rows: '1', maxlength: String(MAX_TEXT), placeholder: s.messageLabel, autocomplete: 'off', disabled: demo, 'aria-describedby': demo ? 'pmw-chat-demo-hint' : null })
173
240
  const send = el(doc, 'button', { type: 'submit', class: 'pmw-chat__send', disabled: demo }, s.send)
174
241
  // One rounded field holds the photo button, the text and the send button; hints and errors go under it.
175
242
  const box = el(doc, 'div', { class: 'pmw-chat__box' })
176
243
  box.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
244
+ // The check row sits ABOVE the field: the composer is pinned to the panel's bottom, so the row
245
+ // opening or collapsing never moves the field the visitor is typing in.
246
+ if (!demo) composer.append(widget('c'))
177
247
  composer.append(box)
178
248
  if (demo) composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-demo-hint' }, s.demoHint))
179
- else composer.append(widget(composer))
180
249
  let picker = null
181
250
  const photoError = el(doc, 'p', { class: 'pmw-chat__error pmw-chat__photo-error', role: 'alert' })
182
251
  if (cfg.photos === true) {
@@ -210,11 +279,49 @@ export function mountChat(root, deps = {}) {
210
279
  field('need', 'textarea', { rows: '3', maxlength: '2000' }),
211
280
  fbError, el(doc, 'button', { type: 'submit', class: 'pmw-chat__send' }, s.fallbackSend),
212
281
  )
213
- if (!demo) fallback.append(widget(fallback))
282
+ if (!demo) fallback.insertBefore(widget('f'), fbError)
214
283
  const body = el(doc, 'div', { class: 'pmw-chat__body' })
215
284
  body.append(log, state, ended, fallback)
216
285
  panel.append(head, body, composer, announcer)
217
286
  root.append(panel)
287
+ turnstile ??= defaultTurnstile(win, widgets)
288
+
289
+ // ---- following the thread ---------------------------------------------------------------
290
+ // PINNED (operator, 2026-11-13): while the visitor is at the bottom (within PIN_SLACK), every
291
+ // change that adds height (a streamed fragment, the stored message replacing it, the typing dots,
292
+ // a photo once decoded, the takeover line, the end block, the composer growing or its check row
293
+ // opening) keeps the thread at the bottom. A visitor who scrolled UP to read stays where they are;
294
+ // reaching the bottom again pins it again. Sending a message is explicit: it always pins. The
295
+ // scroll is smooth, one per animation frame however many fragments land in it (a stream would
296
+ // otherwise stack dozens), and instant under prefers-reduced-motion and when the panel opens.
297
+ // Our own scrolls only ever move down, so a scroll that moves UP away from the bottom is the
298
+ // visitor's.
299
+ let pinned = true
300
+ let lastTop = 0
301
+ let followAt = 0
302
+ const gap = () => body.scrollHeight - body.scrollTop - body.clientHeight
303
+ body.addEventListener('scroll', () => {
304
+ const t = body.scrollTop
305
+ if (gap() <= PIN_SLACK) pinned = true
306
+ else if (t < lastTop) pinned = false
307
+ lastTop = t
308
+ }, { passive: true })
309
+ const nextFrame = (f) => (win.requestAnimationFrame ? win.requestAnimationFrame(f) : win.setTimeout(f, 16))
310
+ function follow(force = false, instant = false) {
311
+ if (force) pinned = true
312
+ if (!pinned || followAt) return
313
+ followAt = nextFrame(() => {
314
+ followAt = 0
315
+ if (!pinned) return
316
+ const still = instant || !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches
317
+ body.scrollTo({ top: body.scrollHeight, behavior: still ? 'instant' : 'smooth' })
318
+ })
319
+ }
320
+ if (typeof win.ResizeObserver === 'function') {
321
+ const ro = new win.ResizeObserver(() => follow())
322
+ ro.observe(log)
323
+ ro.observe(composer)
324
+ }
218
325
 
219
326
  // ---- state ------------------------------------------------------------------------------
220
327
  let session = null
@@ -269,18 +376,37 @@ export function mountChat(root, deps = {}) {
269
376
  // scan back stops at a line already there or at an owner message, so one takeover gets one line
270
377
  // however often the frames repeat it; the bot answering again and a person taking over again
271
378
  // gets another.
272
- function relay(at, by) {
273
- let x = at ? at.previousElementSibling : (typing.parentNode === log ? typing.previousElementSibling : log.lastElementChild)
274
- for (; x; x = x.previousElementSibling) {
275
- if (x.classList.contains('pmw-chat__relay') || x.classList.contains('pmw-chat__msg--owner')) return
276
- if (x.classList.contains('pmw-chat__msg--bot')) break
277
- }
278
- const text = by ? fill(s.relay, { name: by }) : s.relayGeneric
279
- const sep = el(doc, 'li', { class: 'pmw-chat__relay', role: 'separator', 'aria-label': text })
379
+ //
380
+ // THE HAND-BACK (operator, 2026-11-13) is its mirror: where the owner gives the conversation back
381
+ // (« Rendre à l’assistant »), one line « L’assistant virtuel a repris la conversation », always
382
+ // the « assistant virtuel » wording (never the bot's name alone: it must not pass for a person).
383
+ // It goes before the first bot message that follows a person (live, and on replay), or at the end
384
+ // when the mode turns back to bot before the bot speaks. Its scan back stops at a hand-back line
385
+ // already there or at a bot message (nothing to mark), and inserts only past an owner message or a
386
+ // takeover line: a conversation the bot never left gets none. A takeover after it gets its own line.
387
+ const lastLine = () => (typing.parentNode === log ? typing.previousElementSibling : log.lastElementChild)
388
+ const isA = (x, c) => x.classList.contains(c)
389
+ function separator(cls, dotCls, text, at) {
390
+ const sep = el(doc, 'li', { class: cls, role: 'separator', 'aria-label': text })
280
391
  const label = el(doc, 'span', { class: 'pmw-chat__relay-label', 'aria-hidden': 'true' })
281
- label.append(el(doc, 'span', { class: 'pmw-chat__relay-dot' }), text)
392
+ label.append(el(doc, 'span', { class: dotCls }), text)
282
393
  sep.append(label)
283
394
  log.insertBefore(sep, at ?? (typing.parentNode === log ? typing : null))
395
+ follow()
396
+ }
397
+ function relay(at, by) {
398
+ for (let x = at ? at.previousElementSibling : lastLine(); x; x = x.previousElementSibling) {
399
+ if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) return
400
+ if (isA(x, 'pmw-chat__msg--bot') || isA(x, 'pmw-chat__handback')) break
401
+ }
402
+ separator('pmw-chat__relay', 'pmw-chat__relay-dot', by ? fill(s.relay, { name: by }) : s.relayGeneric, at)
403
+ }
404
+ function handback(at) {
405
+ for (let x = at ? at.previousElementSibling : lastLine(); ; x = x.previousElementSibling) {
406
+ if (!x || isA(x, 'pmw-chat__handback') || isA(x, 'pmw-chat__msg--bot')) return
407
+ if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) break
408
+ }
409
+ separator('pmw-chat__handback', 'pmw-chat__relay-dot pmw-chat__relay-dot--back', s.handback, at)
284
410
  }
285
411
  const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
286
412
  const setText = (li, m, text) => {
@@ -292,6 +418,14 @@ export function mountChat(root, deps = {}) {
292
418
  typing.hidden = true
293
419
  win.clearTimeout(typingTimer)
294
420
  }
421
+ /** The dots, until a reply hides them; `ms` also hides them after that long without one. */
422
+ function showTyping(ms) {
423
+ typing.hidden = false
424
+ log.append(typing)
425
+ win.clearTimeout(typingTimer)
426
+ if (ms) typingTimer = win.setTimeout(hideTyping, ms)
427
+ follow()
428
+ }
295
429
  function render(m, { announce }) {
296
430
  if (seen.has(m.seq)) return
297
431
  seen.add(m.seq)
@@ -300,6 +434,7 @@ export function mountChat(root, deps = {}) {
300
434
  if (!li) {
301
435
  li = line(m.id, m.role)
302
436
  if (m.role === 'owner') relay(li, m.by || ownerName)
437
+ else if (m.role === 'bot') handback(li)
303
438
  }
304
439
  li.classList.remove('is-streaming', 'is-pending', 'is-failed')
305
440
  li.querySelector('.pmw-chat__retry')?.remove()
@@ -313,20 +448,20 @@ export function mountChat(root, deps = {}) {
313
448
  if (announce) announcer.textContent = fill(s.said, { name: speaker(m), text: m.text })
314
449
  if (panel.hidden) root.setAttribute('data-unread', '')
315
450
  }
316
- scroll()
451
+ follow()
317
452
  }
318
453
  function delta(id, text) {
319
454
  hideTyping()
320
455
  let li = find(id)
321
456
  if (!li) {
322
457
  li = line(id, 'bot')
458
+ handback(li)
323
459
  li.classList.add('is-streaming')
324
460
  setText(li, { role: 'bot' }, '')
325
461
  }
326
462
  li.lastChild.textContent += text
327
- scroll()
463
+ follow()
328
464
  }
329
- const scroll = () => { log.scrollTop = log.scrollHeight }
330
465
  if (demo) {
331
466
  setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
332
467
  const answer = line('demo-answer', 'bot')
@@ -375,6 +510,8 @@ export function mountChat(root, deps = {}) {
375
510
  ws = sock
376
511
  sock.onopen = () => {
377
512
  attempt = 0
513
+ // A first start: the dots stay until the reply, for TYPING_SHOW_MS at most, like the service's.
514
+ if (slowTimer) { notSlow(); showTyping(TYPING_SHOW_MS) }
378
515
  showState('')
379
516
  sock.send(JSON.stringify({ type: 'hello', since: lastSeq }))
380
517
  for (const entry of pending.values()) transmit(entry)
@@ -389,6 +526,7 @@ export function mountChat(root, deps = {}) {
389
526
  for (const entry of pending.values()) if (entry.tries >= MAX_TRIES) failed(entry)
390
527
  if (e.code >= 4000 && e.code < 5000) return refused(e.code)
391
528
  if (over) return
529
+ notSlow()
392
530
  showState(s.reconnecting)
393
531
  const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
394
532
  attempt++
@@ -408,12 +546,14 @@ export function mountChat(root, deps = {}) {
408
546
  ended.hidden = false
409
547
  composer.hidden = true
410
548
  for (const entry of pending.values()) failed(entry)
549
+ follow()
411
550
  }
412
551
  function status(m) {
413
552
  mode = m.mode === 'human' ? 'human' : 'bot'
414
553
  ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
415
554
  header()
416
555
  if (mode === 'human') relay(null, ownerName)
556
+ else handback(null)
417
557
  if (m.closed) end(s.ended, true)
418
558
  }
419
559
  function frame(m) {
@@ -428,11 +568,7 @@ export function mountChat(root, deps = {}) {
428
568
  return delta(m.id, m.text)
429
569
  case 'typing':
430
570
  if (m.role === 'visitor') return
431
- typing.hidden = false
432
- log.append(typing)
433
- win.clearTimeout(typingTimer)
434
- typingTimer = win.setTimeout(hideTyping, TYPING_SHOW_MS)
435
- return scroll()
571
+ return showTyping(TYPING_SHOW_MS)
436
572
  case 'status':
437
573
  return status(m)
438
574
  case 'fallback':
@@ -473,9 +609,19 @@ export function mountChat(root, deps = {}) {
473
609
  const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
474
610
  return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap' }
475
611
  }
612
+ // THE FIRST START (operator, 2026-11-13) reads like any reply: the typing dots at once, no
613
+ // « Connexion… ». Only a start slower than SLOW_START_MS adds « Un instant… »; the socket's open
614
+ // clears it, a drop says « Connexion perdue, reconnexion… » as before. Nothing is started before
615
+ // the visitor writes: pmeweb-chat counts every /start toward the visitor's conversations of the day
616
+ // (src/ipLimit.ts there), so a panel opened and never used must not spend one. Only the token is
617
+ // prepared ahead (« TURNSTILE » above).
618
+ let slowTimer = null
619
+ const notSlow = () => { win.clearTimeout(slowTimer); slowTimer = null }
476
620
  function begin() {
477
621
  if (starting || session) return starting
478
- showState(s.connecting)
622
+ showState('')
623
+ showTyping()
624
+ slowTimer = win.setTimeout(() => showState(s.oneMoment), SLOW_START_MS)
479
625
  starting = post(composer, {}).then((r) => {
480
626
  starting = null
481
627
  if (r.ok && r.data.conversationId && r.data.visitorKey) {
@@ -485,6 +631,8 @@ export function mountChat(root, deps = {}) {
485
631
  connect()
486
632
  return
487
633
  }
634
+ notSlow()
635
+ hideTyping()
488
636
  for (const entry of pending.values()) failed(entry)
489
637
  if (r.error) return showState(r.error)
490
638
  showState('')
@@ -532,9 +680,12 @@ export function mountChat(root, deps = {}) {
532
680
  const id = uuid()
533
681
  const li = line(id, 'visitor')
534
682
  li.classList.add('is-pending')
535
- li.insertBefore(el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt }), li.lastChild)
683
+ const img = el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt })
684
+ // Its height is known only once decoded: re-pin then (follow() is a no-op if they scrolled up).
685
+ img.addEventListener('load', () => follow(), { once: true })
686
+ li.insertBefore(img, li.lastChild)
536
687
  setText(li, { role: 'visitor' }, '')
537
- scroll()
688
+ follow(true)
538
689
  if (!session) await begin()
539
690
  let res = null
540
691
  if (session) {
@@ -580,7 +731,7 @@ export function mountChat(root, deps = {}) {
580
731
  entry.li.classList.add('is-pending')
581
732
  setText(entry.li, { role: 'visitor' }, entry.text)
582
733
  pending.set(entry.id, entry)
583
- scroll()
734
+ follow(true)
584
735
  if (!session) begin()
585
736
  else if (isOpen()) transmit(entry)
586
737
  else connect()
@@ -616,7 +767,9 @@ export function mountChat(root, deps = {}) {
616
767
  mode = 'bot'
617
768
  ownerName = null
618
769
  header()
619
- for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay')]) li.remove()
770
+ for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay, .pmw-chat__handback')]) li.remove()
771
+ hideTyping()
772
+ notSlow()
620
773
  ended.hidden = true
621
774
  composer.hidden = false
622
775
  input.focus()
@@ -644,7 +797,7 @@ export function mountChat(root, deps = {}) {
644
797
  root.removeAttribute('data-unread')
645
798
  if (session) connect()
646
799
  ;(demo ? closeBtn : composer.hidden ? (fallback.hidden ? restart : fallback.querySelector('input')) : input)?.focus()
647
- scroll()
800
+ follow(true, true)
648
801
  }
649
802
  function close() {
650
803
  panel.hidden = true
@@ -652,6 +805,9 @@ export function mountChat(root, deps = {}) {
652
805
  bubble?.focus()
653
806
  }
654
807
 
808
+ // The early start (« TURNSTILE » above). Off the real hosts there is nothing to start.
809
+ if (!demo) onApproach(root, () => turnstile.load(), { win, settleMs: WARM_MS })
810
+
655
811
  root.pmwChat = { open: show, close, panel }
656
812
  return root.pmwChat
657
813
  }
package/src/worker.js CHANGED
@@ -32,7 +32,7 @@ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
32
32
  import { RPC_HEADER, RPC_VERSION } from './protocol.js'
33
33
 
34
34
  /** This package's version, reported by the health route (a test holds it equal to package.json). */
35
- export const VERSION = '1.2.1'
35
+ export const VERSION = '1.3.1'
36
36
  export { RPC_VERSION }
37
37
 
38
38
  const SERVICE = 'https://pmeweb-chat.internal'