@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- 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
|
+
}
|