@crombolo/server 0.0.0-stage → 0.10.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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
package/src/seo.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Search and sharing on the server: `/sitemap.xml`, `/robots.txt`, the page an
3
+ * unknown address renders, and the backoffice's settings and page preview.
4
+ *
5
+ * Site-wide settings come from `crombolo.config.ts` and may be overridden in the
6
+ * backoffice; an override is stored in `key_value` as JSON, field by field, so
7
+ * clearing one falls back to the config again.
8
+ */
9
+ import type { Principal } from '@crombolo/api-management'
10
+ import { hasSection } from '@crombolo/api-management'
11
+ import { SYSTEM_CONTENT_TYPE_KEYS } from '@crombolo/core'
12
+ import { ContentTypeRepository, type Db, readKeyValue, writeKeyValue } from '@crombolo/data'
13
+ import {
14
+ effectiveMeta,
15
+ type PublishedCache,
16
+ type PublishedContent,
17
+ renderSitemap,
18
+ robotsTxt,
19
+ type SeoSiteDefaults,
20
+ sitemapEntries,
21
+ urlsIn,
22
+ } from '@crombolo/render'
23
+ import type { CromboloConfig, NotFoundRule } from './config.ts'
24
+
25
+ export const SEO_SETTINGS_KEY = 'Crombolo.Seo.Settings'
26
+
27
+ export interface SeoSettings {
28
+ sitemap: boolean
29
+ indexable: boolean
30
+ robotsTxt: string
31
+ siteName: string
32
+ titleSuffix: string
33
+ defaultImage: string
34
+ notFoundPage: NotFoundRule[]
35
+ }
36
+
37
+ export type SeoSettingsOverrides = { [K in keyof SeoSettings]?: SeoSettings[K] | null }
38
+
39
+ export type SettingSource = 'backoffice' | 'config' | 'default'
40
+
41
+ export const DEFAULT_ROBOTS_TXT = 'User-agent: *\nAllow: /'
42
+
43
+ const FIELDS = [
44
+ 'sitemap',
45
+ 'indexable',
46
+ 'robotsTxt',
47
+ 'siteName',
48
+ 'titleSuffix',
49
+ 'defaultImage',
50
+ 'notFoundPage',
51
+ ] as const satisfies ReadonlyArray<keyof SeoSettings>
52
+
53
+ /** How long a node trusts the settings it read; another node's save shows within this. */
54
+ const SETTINGS_TTL_MS = 10_000
55
+
56
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
57
+
58
+ /**
59
+ * The scheme and host the visitor used: a proxy's forwarded values first, then
60
+ * the request's own, then the configured public address.
61
+ */
62
+ export function originOf(request: Request, config: Pick<CromboloConfig, 'applicationUrl'>): string {
63
+ const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim()
64
+ const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim()
65
+ try {
66
+ const url = new URL(request.url)
67
+ const host = forwardedHost || request.headers.get('host') || url.host
68
+ const proto = forwardedProto || url.protocol.replace(/:$/, '')
69
+ if (host) return `${proto}://${host}`
70
+ } catch {
71
+ // A request without an absolute URL falls through to the configured address.
72
+ }
73
+ return config.applicationUrl.replace(/\/$/, '')
74
+ }
75
+
76
+ /** Refusal text for a settings body, or the overrides it sets. */
77
+ export function parseSettingsBody(
78
+ body: unknown,
79
+ ): { ok: true; overrides: SeoSettingsOverrides } | { ok: false; message: string } {
80
+ if (!body || typeof body !== 'object' || Array.isArray(body))
81
+ return { ok: false, message: 'Expected an object of settings.' }
82
+ const input = body as Record<string, unknown>
83
+ const out: SeoSettingsOverrides = {}
84
+ for (const field of FIELDS) {
85
+ if (!(field in input)) continue
86
+ const value = input[field]
87
+ if (value === null) {
88
+ out[field] = null
89
+ continue
90
+ }
91
+ switch (field) {
92
+ case 'sitemap':
93
+ case 'indexable':
94
+ if (typeof value !== 'boolean')
95
+ return { ok: false, message: `${field} must be true or false.` }
96
+ out[field] = value
97
+ break
98
+ case 'notFoundPage': {
99
+ const rules = parseRules(value)
100
+ if (!rules) return { ok: false, message: 'Choose a page for each not-found rule.' }
101
+ out.notFoundPage = rules
102
+ break
103
+ }
104
+ default:
105
+ if (typeof value !== 'string') return { ok: false, message: `${field} must be text.` }
106
+ out[field] = value
107
+ }
108
+ }
109
+ return { ok: true, overrides: out }
110
+ }
111
+
112
+ function parseRules(value: unknown): NotFoundRule[] | undefined {
113
+ if (typeof value === 'string') return UUID.test(value) ? [{ document: value }] : undefined
114
+ if (!Array.isArray(value)) return undefined
115
+ const rules: NotFoundRule[] = []
116
+ for (const item of value) {
117
+ if (!item || typeof item !== 'object') return undefined
118
+ const { document, host, culture } = item as Record<string, unknown>
119
+ if (typeof document !== 'string' || !UUID.test(document)) return undefined
120
+ const rule: NotFoundRule = { document }
121
+ if (typeof host === 'string' && host.trim()) rule.host = host.trim().toLowerCase()
122
+ if (typeof culture === 'string' && culture.trim()) rule.culture = culture.trim()
123
+ rules.push(rule)
124
+ }
125
+ return rules
126
+ }
127
+
128
+ /** The most specific rule for a request: hostname and language, then either, then neither. */
129
+ export function chooseNotFoundRule(
130
+ rules: readonly NotFoundRule[],
131
+ host: string,
132
+ culture: string | null,
133
+ ): NotFoundRule | undefined {
134
+ const bare = host.toLowerCase().replace(/:\d+$/, '')
135
+ const hostMatches = (r: NotFoundRule) =>
136
+ r.host === undefined || r.host === bare || r.host === host.toLowerCase()
137
+ const cultureMatches = (r: NotFoundRule) =>
138
+ r.culture === undefined || r.culture.toLowerCase() === (culture ?? '').toLowerCase()
139
+ const score = (r: NotFoundRule) => (r.host ? 2 : 0) + (r.culture ? 1 : 0)
140
+ return rules
141
+ .filter((r) => hostMatches(r) && cultureMatches(r))
142
+ .sort((a, b) => score(b) - score(a))[0]
143
+ }
144
+
145
+ export interface DraftModel {
146
+ content: PublishedContent
147
+ contentTypeAlias: string
148
+ published: boolean
149
+ culture: string | null
150
+ }
151
+
152
+ export interface SeoHostOptions {
153
+ db: Db
154
+ config: CromboloConfig
155
+ cache: PublishedCache
156
+ /** Whether a visitor who is not signed in would be turned away from a page. */
157
+ isProtected(content: PublishedContent): Promise<boolean>
158
+ /** A document's latest saved version, as the preview renders it. */
159
+ draft(key: string, culture: string | null): Promise<DraftModel | undefined>
160
+ }
161
+
162
+ export interface SeoHost {
163
+ settings(): Promise<{
164
+ effective: SeoSettings
165
+ overrides: SeoSettingsOverrides
166
+ sources: Record<keyof SeoSettings, SettingSource>
167
+ }>
168
+ siteDefaults(): Promise<SeoSiteDefaults>
169
+ sitemap(request: Request, host: string): Promise<Response>
170
+ robots(request: Request): Promise<Response>
171
+ notFound(
172
+ host: string,
173
+ pathname: string,
174
+ ): Promise<{ content: PublishedContent; culture: string | null } | undefined>
175
+ /** Backoffice routes below `<backoffice>/crombolo/api/seo`. */
176
+ handle(request: Request, subpath: string, principal: Principal): Promise<Response | undefined>
177
+ }
178
+
179
+ export function createSeoHost(options: SeoHostOptions): SeoHost {
180
+ const { db, config, cache } = options
181
+ let memo: { at: number; overrides: SeoSettingsOverrides } | undefined
182
+
183
+ const readOverrides = async (): Promise<SeoSettingsOverrides> => {
184
+ if (memo && Date.now() - memo.at < SETTINGS_TTL_MS) return memo.overrides
185
+ const raw = await readKeyValue(db, SEO_SETTINGS_KEY)
186
+ let overrides: SeoSettingsOverrides = {}
187
+ if (raw) {
188
+ const parsed = parseSettingsBody(safeJson(raw))
189
+ if (parsed.ok) overrides = parsed.overrides
190
+ }
191
+ memo = { at: Date.now(), overrides }
192
+ return overrides
193
+ }
194
+
195
+ const defaults = (): SeoSettings => ({
196
+ sitemap: true,
197
+ indexable: true,
198
+ robotsTxt: DEFAULT_ROBOTS_TXT,
199
+ siteName: config.siteName,
200
+ titleSuffix: '',
201
+ defaultImage: '',
202
+ notFoundPage: [],
203
+ })
204
+
205
+ const fromConfig = (): Partial<SeoSettings> => {
206
+ const seo = config.seo ?? {}
207
+ const out: Partial<SeoSettings> = {}
208
+ for (const field of FIELDS) {
209
+ const value = seo[field]
210
+ if (value === undefined) continue
211
+ if (field === 'notFoundPage') out.notFoundPage = parseRules(value) ?? []
212
+ else (out as Record<string, unknown>)[field] = value
213
+ }
214
+ return out
215
+ }
216
+
217
+ const settings: SeoHost['settings'] = async () => {
218
+ const overrides = await readOverrides()
219
+ const configured = fromConfig()
220
+ const effective = defaults()
221
+ const sources = {} as Record<keyof SeoSettings, SettingSource>
222
+ for (const field of FIELDS) {
223
+ const own = overrides[field]
224
+ if (own !== undefined && own !== null) {
225
+ ;(effective as unknown as Record<string, unknown>)[field] = own
226
+ sources[field] = 'backoffice'
227
+ } else if (configured[field] !== undefined) {
228
+ ;(effective as unknown as Record<string, unknown>)[field] = configured[field]
229
+ sources[field] = 'config'
230
+ } else sources[field] = 'default'
231
+ }
232
+ return { effective, overrides, sources }
233
+ }
234
+
235
+ /** A media item's address when given its key, else the address as written. */
236
+ const imageAddress = async (value: string): Promise<string | undefined> => {
237
+ const trimmed = value.trim()
238
+ if (!trimmed) return undefined
239
+ if (!UUID.test(trimmed)) return trimmed
240
+ const snapshot = await cache.snapshot()
241
+ return snapshot.media.get(trimmed.toLowerCase())?.url
242
+ }
243
+
244
+ const siteDefaults: SeoHost['siteDefaults'] = async () => {
245
+ const { effective } = await settings()
246
+ return {
247
+ siteName: effective.siteName,
248
+ titleSuffix: effective.titleSuffix || undefined,
249
+ defaultImage: await imageAddress(effective.defaultImage),
250
+ indexable: effective.indexable,
251
+ }
252
+ }
253
+
254
+ const cacheControl = config.development ? 'no-cache' : 'public, max-age=3600'
255
+
256
+ const sitemap: SeoHost['sitemap'] = async (request, host) => {
257
+ const { effective } = await settings()
258
+ if (!effective.sitemap || !effective.indexable)
259
+ return new Response('Not Found', {
260
+ status: 404,
261
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
262
+ })
263
+ const snapshot = await cache.snapshot()
264
+ const entries = sitemapEntries(snapshot, { host, origin: originOf(request, config) })
265
+ const open: typeof entries = []
266
+ for (const entry of entries) if (!(await options.isProtected(entry.content))) open.push(entry)
267
+ return new Response(renderSitemap(open), {
268
+ headers: { 'content-type': 'application/xml; charset=utf-8', 'cache-control': cacheControl },
269
+ })
270
+ }
271
+
272
+ const robots: SeoHost['robots'] = async (request) => {
273
+ const { effective } = await settings()
274
+ const text = robotsTxt(effective.robotsTxt, {
275
+ origin: originOf(request, config),
276
+ indexable: effective.indexable,
277
+ sitemap: effective.sitemap,
278
+ })
279
+ return new Response(text, {
280
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': cacheControl },
281
+ })
282
+ }
283
+
284
+ const notFound: SeoHost['notFound'] = async (host, pathname) => {
285
+ const { effective } = await settings()
286
+ if (effective.notFoundPage.length === 0) return undefined
287
+ const culture = await cache.cultureFor(host, pathname)
288
+ const rule = chooseNotFoundRule(effective.notFoundPage, host, culture)
289
+ if (!rule) return undefined
290
+ const content = await cache.byKey(rule.document, culture)
291
+ return content ? { content, culture } : undefined
292
+ }
293
+
294
+ /** Whether a content type has the "Search & sharing" fields, itself or by composition. */
295
+ const composesSeoFields = async (alias: string): Promise<boolean> => {
296
+ const repo = new ContentTypeRepository(db)
297
+ const seen = new Set<string>()
298
+ let pending = [(await repo.byAlias(alias))?.key].filter((k): k is string => Boolean(k))
299
+ while (pending.length > 0) {
300
+ const next: string[] = []
301
+ for (const key of pending) {
302
+ const normal = key.toLowerCase()
303
+ if (normal === SYSTEM_CONTENT_TYPE_KEYS.SeoFields) return true
304
+ if (seen.has(normal)) continue
305
+ seen.add(normal)
306
+ const type = await repo.byKey(key)
307
+ for (const c of type?.compositions ?? []) next.push(c.contentTypeKey)
308
+ }
309
+ pending = next
310
+ }
311
+ return false
312
+ }
313
+
314
+ const preview = async (request: Request): Promise<Response> => {
315
+ const url = new URL(request.url)
316
+ const key = url.searchParams.get('document') ?? ''
317
+ if (!UUID.test(key)) return Response.json({ message: 'Choose a page.' }, { status: 400 })
318
+ const draft = await options.draft(key, url.searchParams.get('culture') || null)
319
+ if (!draft) return Response.json({ message: 'That page could not be found.' }, { status: 404 })
320
+ const origin = originOf(request, config)
321
+ const snapshot = await cache.snapshot()
322
+ const meta = effectiveMeta(draft.content, {
323
+ origin,
324
+ alternates: urlsIn(snapshot, key),
325
+ site: await siteDefaults(),
326
+ })
327
+ return Response.json({
328
+ hasSeoFields: await composesSeoFields(draft.contentTypeAlias),
329
+ published: draft.published,
330
+ culture: draft.culture,
331
+ meta,
332
+ snippet: {
333
+ title: meta.fullTitle,
334
+ url: meta.canonical.value ?? '',
335
+ description: meta.description.value ?? '',
336
+ },
337
+ })
338
+ }
339
+
340
+ const handle: SeoHost['handle'] = async (request, subpath, principal) => {
341
+ if (subpath === '/preview' && request.method === 'GET') return preview(request)
342
+ if (subpath !== '/settings') return undefined
343
+ if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
344
+ if (request.method === 'GET') return Response.json(await settings())
345
+ if (request.method !== 'PUT')
346
+ return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET, PUT' } })
347
+ const parsed = parseSettingsBody(await request.json().catch(() => undefined))
348
+ if (!parsed.ok) return Response.json({ message: parsed.message }, { status: 400 })
349
+ memo = undefined
350
+ const merged: SeoSettingsOverrides = { ...(await readOverrides()) }
351
+ for (const field of FIELDS) {
352
+ if (!(field in parsed.overrides)) continue
353
+ const value = parsed.overrides[field]
354
+ if (value === null) delete merged[field]
355
+ else (merged as Record<string, unknown>)[field] = value
356
+ }
357
+ await writeKeyValue(db, SEO_SETTINGS_KEY, JSON.stringify(merged))
358
+ memo = undefined
359
+ return Response.json(await settings())
360
+ }
361
+
362
+ return { settings, siteDefaults, sitemap, robots, notFound, handle }
363
+ }
364
+
365
+ function safeJson(text: string): unknown {
366
+ try {
367
+ return JSON.parse(text)
368
+ } catch {
369
+ return undefined
370
+ }
371
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The backoffice's live-update channel: `/umbraco/serverEventHub`, which the
3
+ * client opens with `@microsoft/signalr` over a plain WebSocket (we report
4
+ * `signalR.skipNegotiation`). This speaks just enough of SignalR's JSON hub
5
+ * protocol to hold the connection: the handshake, keep-alive pings, and close.
6
+ * Events are sent through `broadcast` once server-side changes publish them.
7
+ *
8
+ * Protocol: every message is JSON followed by the record separator U+001E.
9
+ * The client opens with `{"protocol":"json","version":1}`; the server answers
10
+ * `{}`. Pings are `{"type":6}`, invocations `{"type":1,...}`, close `{"type":7}`.
11
+ */
12
+ import type { Server, ServerWebSocket } from 'bun'
13
+
14
+ const RS = '\u001e'
15
+
16
+ export interface HubSocketData {
17
+ userKey: string
18
+ handshaken: boolean
19
+ /** Which hub the socket belongs to, when several share one server. */
20
+ hub: string
21
+ }
22
+
23
+ export interface ServerEventHubOptions {
24
+ /** How often to ping every socket; SignalR clients time out after 30 s of silence. */
25
+ pingMs?: number
26
+ /** Tags this hub's sockets, so `combineHubs` can route them. */
27
+ name?: string
28
+ }
29
+
30
+ export interface ServerEventHub {
31
+ readonly name: string
32
+ /** Upgrades a signed-in request; answers 401 otherwise. `undefined` means upgraded. */
33
+ upgrade(
34
+ request: Request,
35
+ server: Server<HubSocketData>,
36
+ userKey: string | undefined,
37
+ ): Response | undefined
38
+ websocket: {
39
+ open(ws: ServerWebSocket<HubSocketData>): void
40
+ message(ws: ServerWebSocket<HubSocketData>, message: string | Buffer): void
41
+ close(ws: ServerWebSocket<HubSocketData>): void
42
+ }
43
+ /** Sends a hub invocation to every connected client. */
44
+ broadcast(target: string, args: unknown[]): number
45
+ connections(): number
46
+ stop(): void
47
+ }
48
+
49
+ export function createServerEventHub(options: ServerEventHubOptions = {}): ServerEventHub {
50
+ const sockets = new Set<ServerWebSocket<HubSocketData>>()
51
+ const send = (ws: ServerWebSocket<HubSocketData>, message: unknown) =>
52
+ ws.send(`${JSON.stringify(message)}${RS}`)
53
+
54
+ const timer = setInterval(() => {
55
+ for (const ws of sockets) if (ws.data.handshaken) send(ws, { type: 6 })
56
+ }, options.pingMs ?? 15_000)
57
+ timer.unref()
58
+
59
+ const name = options.name ?? 'serverEventHub'
60
+ return {
61
+ name,
62
+ upgrade(request, server, userKey) {
63
+ if (!userKey) return new Response('Unauthorized', { status: 401 })
64
+ const upgraded = server.upgrade(request, { data: { userKey, handshaken: false, hub: name } })
65
+ return upgraded ? undefined : new Response('Expected a WebSocket upgrade', { status: 400 })
66
+ },
67
+
68
+ websocket: {
69
+ open(ws) {
70
+ sockets.add(ws)
71
+ },
72
+ message(ws, message) {
73
+ const text = typeof message === 'string' ? message : message.toString('utf8')
74
+ for (const record of text.split(RS)) {
75
+ if (!record) continue
76
+ let parsed: Record<string, unknown>
77
+ try {
78
+ parsed = JSON.parse(record) as Record<string, unknown>
79
+ } catch {
80
+ ws.close(1003, 'Invalid message')
81
+ return
82
+ }
83
+ if (!ws.data.handshaken) {
84
+ if (parsed.protocol !== 'json') {
85
+ send(ws, { error: `The protocol '${String(parsed.protocol)}' is not supported.` })
86
+ ws.close()
87
+ return
88
+ }
89
+ ws.data.handshaken = true
90
+ send(ws, {})
91
+ continue
92
+ }
93
+ if (parsed.type === 7) ws.close()
94
+ // Pings (6) and anything else the client sends need no answer.
95
+ }
96
+ },
97
+ close(ws) {
98
+ sockets.delete(ws)
99
+ },
100
+ },
101
+
102
+ broadcast(target, args) {
103
+ let sent = 0
104
+ for (const ws of sockets) {
105
+ if (!ws.data.handshaken) continue
106
+ send(ws, { type: 1, target, arguments: args })
107
+ sent += 1
108
+ }
109
+ return sent
110
+ },
111
+
112
+ connections: () => sockets.size,
113
+
114
+ stop() {
115
+ clearInterval(timer)
116
+ for (const ws of sockets) ws.close(1001, 'Server shutting down')
117
+ sockets.clear()
118
+ },
119
+ }
120
+ }
121
+
122
+ /** One Bun websocket handler for several hubs, routed by the hub each socket joined. */
123
+ export function combineHubs(hubs: readonly ServerEventHub[]): ServerEventHub['websocket'] {
124
+ const of = (ws: ServerWebSocket<HubSocketData>) =>
125
+ (hubs.find((hub) => hub.name === ws.data.hub) ?? hubs[0]) as ServerEventHub
126
+ return {
127
+ open: (ws) => of(ws).websocket.open(ws),
128
+ message: (ws, message) => of(ws).websocket.message(ws, message),
129
+ close: (ws) => of(ws).websocket.close(ws),
130
+ }
131
+ }