@byline/host-tanstack-start 4.14.1 → 4.16.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 (69) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +4 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.js +26 -2
  3. package/dist/admin-shell/chrome/admin-menu-drawer_module.css +1 -1
  4. package/dist/admin-shell/collections/edit.js +142 -2
  5. package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
  6. package/dist/admin-shell/scheduled-publications/list.js +295 -0
  7. package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
  8. package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
  9. package/dist/integrations/analytics-agent.d.ts +20 -0
  10. package/dist/integrations/analytics-agent.js +57 -0
  11. package/dist/integrations/analytics-events.d.ts +51 -0
  12. package/dist/integrations/analytics-events.js +129 -0
  13. package/dist/integrations/serve-analytics-agent.d.ts +8 -0
  14. package/dist/integrations/serve-analytics-agent.js +11 -0
  15. package/dist/routes/analytics-range.d.ts +12 -0
  16. package/dist/routes/analytics-range.js +35 -0
  17. package/dist/routes/create-admin-analytics-route.d.ts +8 -0
  18. package/dist/routes/create-admin-analytics-route.js +108 -0
  19. package/dist/routes/create-admin-layout-route.js +15 -4
  20. package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
  21. package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
  22. package/dist/routes/index.d.ts +2 -0
  23. package/dist/routes/index.js +2 -0
  24. package/dist/server-fns/analytics/index.d.ts +9 -0
  25. package/dist/server-fns/analytics/index.js +2 -0
  26. package/dist/server-fns/analytics/maintenance.d.ts +22 -0
  27. package/dist/server-fns/analytics/maintenance.js +42 -0
  28. package/dist/server-fns/analytics/queries.d.ts +25 -0
  29. package/dist/server-fns/analytics/queries.js +54 -0
  30. package/dist/server-fns/collections/get.d.ts +5 -0
  31. package/dist/server-fns/collections/get.js +19 -3
  32. package/dist/server-fns/collections/index.d.ts +1 -0
  33. package/dist/server-fns/collections/index.js +1 -0
  34. package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
  35. package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
  36. package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
  37. package/dist/server-fns/collections/scheduled-publication.js +157 -0
  38. package/dist/vite/analytics-dev.d.ts +22 -0
  39. package/dist/vite/analytics-dev.js +28 -0
  40. package/dist/vite/index.d.ts +1 -0
  41. package/dist/vite/index.js +1 -0
  42. package/package.json +14 -8
  43. package/src/admin-shell/chrome/admin-menu-drawer.module.css +7 -1
  44. package/src/admin-shell/chrome/admin-menu-drawer.tsx +37 -1
  45. package/src/admin-shell/collections/edit.tsx +140 -1
  46. package/src/admin-shell/scheduled-publications/list.module.css +105 -0
  47. package/src/admin-shell/scheduled-publications/list.tsx +292 -0
  48. package/src/integrations/analytics-agent.tsx +91 -0
  49. package/src/integrations/analytics-events.test.node.ts +245 -0
  50. package/src/integrations/analytics-events.ts +224 -0
  51. package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
  52. package/src/integrations/serve-analytics-agent.ts +19 -0
  53. package/src/routes/analytics-range.ts +55 -0
  54. package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
  55. package/src/routes/create-admin-analytics-route.tsx +109 -0
  56. package/src/routes/create-admin-layout-route.tsx +19 -7
  57. package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
  58. package/src/routes/index.ts +2 -0
  59. package/src/server-fns/analytics/index.ts +23 -0
  60. package/src/server-fns/analytics/maintenance.ts +54 -0
  61. package/src/server-fns/analytics/queries.ts +86 -0
  62. package/src/server-fns/collections/get.ts +30 -1
  63. package/src/server-fns/collections/index.ts +1 -0
  64. package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
  65. package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
  66. package/src/server-fns/collections/scheduled-publication.ts +270 -0
  67. package/src/vite/analytics-dev.test.node.ts +126 -0
  68. package/src/vite/analytics-dev.ts +56 -0
  69. package/src/vite/index.ts +1 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createServerFn } from '@tanstack/react-start'
10
+
11
+ import type { DocumentPublishScheduleInfo } from '@byline/client'
12
+ import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
13
+ import {
14
+ type DocumentPublishSchedule,
15
+ type DocumentPublishScheduleState,
16
+ ERR_NOT_FOUND,
17
+ ERR_VALIDATION,
18
+ type RecurringTaskHealth,
19
+ } from '@byline/core'
20
+ import { listDocumentPublishSchedules } from '@byline/core/services'
21
+
22
+ import { bylineCore } from '../../integrations/byline-core.js'
23
+ import { omitScheduleExecutionState } from './scheduled-publication-response.js'
24
+ import { serialise } from './utils.js'
25
+
26
+ const TASK_NAME = 'documents.publish-scheduled'
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
28
+
29
+ export interface ScheduledPublicationRuntime {
30
+ enabled: boolean
31
+ health: {
32
+ status: 'healthy' | 'failed' | 'stale' | 'not_started'
33
+ task: SerializedRecurringTaskHealth | null
34
+ } | null
35
+ }
36
+
37
+ type SerializedRecurringTaskHealth = Omit<
38
+ RecurringTaskHealth,
39
+ 'nextRunAt' | 'lastStartedAt' | 'lastSucceededAt' | 'lastFailedAt' | 'databaseNow'
40
+ > & {
41
+ nextRunAt: string
42
+ lastStartedAt: string | null
43
+ lastSucceededAt: string | null
44
+ lastFailedAt: string | null
45
+ databaseNow: string
46
+ }
47
+
48
+ export type SerializedDocumentPublishSchedule = Omit<
49
+ DocumentPublishScheduleInfo,
50
+ | 'publishAt'
51
+ | 'suspendedAt'
52
+ | 'lastAuthorizedAt'
53
+ | 'scheduledAt'
54
+ | 'updatedAt'
55
+ | 'lastAttemptAt'
56
+ | 'nextAttemptAt'
57
+ > & {
58
+ publishAt: string
59
+ suspendedAt: string | null
60
+ lastAuthorizedAt: string
61
+ scheduledAt: string
62
+ updatedAt: string
63
+ lastAttemptAt: string | null
64
+ nextAttemptAt: string
65
+ }
66
+
67
+ export interface ScheduledPublicationListItem extends SerializedDocumentPublishSchedule {
68
+ collectionPath: string
69
+ collectionLabel: string
70
+ documentPath: string | null
71
+ /**
72
+ * Display name for `lastAuthorizedBy` — full name, or email when the account
73
+ * has no name set. `null` when the account no longer exists, which is a real
74
+ * state rather than an error: the authorizer column is not a foreign key, so
75
+ * a schedule outlives the account that authorized it. Callers fall back to
76
+ * showing the id.
77
+ */
78
+ lastAuthorizedByName: string | null
79
+ }
80
+
81
+ export interface ScheduledPublicationListResponse {
82
+ schedules: ScheduledPublicationListItem[]
83
+ meta: {
84
+ page: number
85
+ pageSize: number
86
+ total: number
87
+ totalPages: number
88
+ }
89
+ runtime: ScheduledPublicationRuntime
90
+ }
91
+
92
+ function assertFeatureEnabled(): void {
93
+ if (bylineCore().config.scheduledPublication?.enabled === true) return
94
+ throw ERR_NOT_FOUND({ message: 'Scheduled publication is not enabled for this installation' })
95
+ }
96
+
97
+ function serializeSchedule(
98
+ schedule: DocumentPublishScheduleInfo | DocumentPublishSchedule
99
+ ): SerializedDocumentPublishSchedule {
100
+ return serialise(
101
+ omitScheduleExecutionState(schedule)
102
+ ) as unknown as SerializedDocumentPublishSchedule
103
+ }
104
+
105
+ async function scheduledPublicationRuntime(): Promise<ScheduledPublicationRuntime> {
106
+ const core = bylineCore()
107
+ if (core.config.scheduledPublication?.enabled !== true) {
108
+ return { enabled: false, health: null }
109
+ }
110
+
111
+ const scheduler = core.db.scheduler
112
+ if (scheduler == null) {
113
+ return { enabled: true, health: { status: 'not_started', task: null } }
114
+ }
115
+ const [task] = await scheduler.health([TASK_NAME])
116
+ if (task == null) {
117
+ return { enabled: true, health: { status: 'not_started', task: null } }
118
+ }
119
+
120
+ const staleAfter = task.nextRunAt.getTime() + task.intervalMs * 2
121
+ const status = task.leaseExpired
122
+ ? 'stale'
123
+ : task.lastStatus === 'failed'
124
+ ? 'failed'
125
+ : staleAfter < task.databaseNow.getTime()
126
+ ? 'stale'
127
+ : 'healthy'
128
+ return {
129
+ enabled: true,
130
+ health: {
131
+ status,
132
+ task: serialise(task) as unknown as SerializedRecurringTaskHealth,
133
+ },
134
+ }
135
+ }
136
+
137
+ /** Authenticated feature discovery for the admin shell and health warning. */
138
+ export const getScheduledPublicationRuntime = createServerFn({ method: 'GET' }).handler(
139
+ async () => {
140
+ await getAdminRequestContext()
141
+ return scheduledPublicationRuntime()
142
+ }
143
+ )
144
+
145
+ export const scheduleCollectionDocumentPublish = createServerFn({ method: 'POST' })
146
+ .validator(
147
+ (input: { collection: string; id: string; publishAt: string; expectedVersionId: string }) =>
148
+ input
149
+ )
150
+ .handler(async ({ data }) => {
151
+ assertFeatureEnabled()
152
+ const schedule = await getAdminBylineClient()
153
+ .collection(data.collection)
154
+ .schedulePublish(data.id, {
155
+ publishAt: data.publishAt,
156
+ expectedVersionId: data.expectedVersionId,
157
+ })
158
+ return serializeSchedule(schedule)
159
+ })
160
+
161
+ export const confirmCollectionDocumentScheduledPublish = createServerFn({ method: 'POST' })
162
+ .validator((input: { collection: string; id: string; expectedVersionId: string }) => input)
163
+ .handler(async ({ data }) => {
164
+ assertFeatureEnabled()
165
+ const schedule = await getAdminBylineClient()
166
+ .collection(data.collection)
167
+ .confirmScheduledPublish(data.id, { expectedVersionId: data.expectedVersionId })
168
+ return serializeSchedule(schedule)
169
+ })
170
+
171
+ export const cancelCollectionDocumentScheduledPublish = createServerFn({ method: 'POST' })
172
+ .validator((input: { collection: string; id: string }) => input)
173
+ .handler(async ({ data }) => {
174
+ assertFeatureEnabled()
175
+ const schedule = await getAdminBylineClient()
176
+ .collection(data.collection)
177
+ .cancelScheduledPublish(data.id)
178
+ return schedule == null
179
+ ? { status: 'not_found' as const }
180
+ : { status: 'cancelled' as const, schedule: serializeSchedule(schedule) }
181
+ })
182
+
183
+ export const listScheduledPublications = createServerFn({ method: 'GET' })
184
+ .validator(
185
+ (input: {
186
+ states?: readonly DocumentPublishScheduleState[]
187
+ lastAuthorizedBy?: string
188
+ page?: number
189
+ pageSize?: number
190
+ }) => input
191
+ )
192
+ .handler(async ({ data }) => {
193
+ assertFeatureEnabled()
194
+ if (data.states?.some((state) => state !== 'armed' && state !== 'needs_reconfirm') === true) {
195
+ throw ERR_VALIDATION({ message: 'state must be armed or needs_reconfirm' })
196
+ }
197
+ if (data.lastAuthorizedBy != null && !UUID_RE.test(data.lastAuthorizedBy)) {
198
+ throw ERR_VALIDATION({ message: 'lastAuthorizedBy must be a UUID' })
199
+ }
200
+ const core = bylineCore()
201
+ const page = data.page ?? 1
202
+ const pageSize = data.pageSize ?? 25
203
+ const requestContext = await getAdminRequestContext()
204
+ const result = await listDocumentPublishSchedules(core, requestContext, {
205
+ states: data.states,
206
+ lastAuthorizedBy: data.lastAuthorizedBy,
207
+ page,
208
+ pageSize,
209
+ })
210
+ const collections = new Map(
211
+ core.collections.map((definition) => {
212
+ const record = core.getCollectionRecord(definition.path)
213
+ return [record.collectionId, definition] as const
214
+ })
215
+ )
216
+ // Resolve authorizer names in one query for the whole page rather than one
217
+ // per row. `last_authorized_by` is deliberately not a foreign key — deleting
218
+ // an admin account must not revoke a schedule it authorized — so an id here
219
+ // may simply have no user row left, and the caller falls back to the id.
220
+ const authorizerIds = [
221
+ ...new Set(
222
+ result.schedules
223
+ .map((schedule) => schedule.lastAuthorizedBy)
224
+ .filter((id): id is string => id != null)
225
+ ),
226
+ ]
227
+ const authorizerNames = new Map<string, string>()
228
+ // `adminStore` is optional on core — an installation can run without the
229
+ // admin subsystem — so a missing store simply means no names to resolve and
230
+ // the column falls back to ids.
231
+ const adminUsers = core.adminStore?.adminUsers
232
+ if (authorizerIds.length > 0 && adminUsers != null) {
233
+ const users = await adminUsers.getByIds(authorizerIds)
234
+ for (const user of users) {
235
+ const fullName = [user.given_name, user.family_name].filter(Boolean).join(' ').trim()
236
+ authorizerNames.set(user.id, fullName.length > 0 ? fullName : user.email)
237
+ }
238
+ }
239
+
240
+ const schedules = await Promise.all(
241
+ result.schedules.map(async (schedule): Promise<ScheduledPublicationListItem> => {
242
+ const definition = collections.get(schedule.collectionId)
243
+ const documentPath = await core.db.queries.documents.getCurrentPath({
244
+ collection_id: schedule.collectionId,
245
+ document_id: schedule.documentId,
246
+ })
247
+ return {
248
+ ...serializeSchedule(schedule),
249
+ collectionPath: definition?.path ?? schedule.collectionId,
250
+ collectionLabel: definition?.labels.plural ?? definition?.path ?? schedule.collectionId,
251
+ documentPath,
252
+ lastAuthorizedByName:
253
+ schedule.lastAuthorizedBy == null
254
+ ? null
255
+ : (authorizerNames.get(schedule.lastAuthorizedBy) ?? null),
256
+ }
257
+ })
258
+ )
259
+
260
+ return {
261
+ schedules,
262
+ meta: {
263
+ page,
264
+ pageSize,
265
+ total: result.total,
266
+ totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
267
+ },
268
+ runtime: await scheduledPublicationRuntime(),
269
+ } satisfies ScheduledPublicationListResponse
270
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+
11
+ import { ANALYTICS_AGENT_SOURCE } from '@byline/analytics-agent/source'
12
+ import type { Plugin } from 'vite'
13
+ import { describe, expect, it, vi } from 'vitest'
14
+
15
+ import { bylineAnalyticsDev } from './analytics-dev.js'
16
+
17
+ type Middleware = (
18
+ request: IncomingMessage,
19
+ response: ServerResponse,
20
+ next: (error?: unknown) => void
21
+ ) => void | Promise<void>
22
+
23
+ describe('bylineAnalyticsDev', () => {
24
+ it('directly serves browser script requests before Vite can transform them', async () => {
25
+ const handler = captureMiddleware(bylineAnalyticsDev())
26
+ const request = requestStub('GET', '/b.js?version=current', {
27
+ accept: '*/*',
28
+ 'sec-fetch-dest': 'script',
29
+ })
30
+ const { response, headers, end } = responseStub()
31
+ const next = vi.fn()
32
+
33
+ await handler(request, response, next)
34
+
35
+ expect(response.statusCode).toBe(200)
36
+ expect(headers).toEqual({
37
+ 'cache-control': 'public, max-age=3600, must-revalidate',
38
+ 'content-type': 'text/javascript; charset=utf-8',
39
+ 'x-content-type-options': 'nosniff',
40
+ })
41
+ expect(end).toHaveBeenCalledWith(ANALYTICS_AGENT_SOURCE)
42
+ expect(next).not.toHaveBeenCalled()
43
+ expect(request.headers).toEqual({
44
+ accept: '*/*',
45
+ 'sec-fetch-dest': 'script',
46
+ })
47
+ })
48
+
49
+ it('continues unrelated requests through the Vite middleware chain', async () => {
50
+ const handler = captureMiddleware(bylineAnalyticsDev())
51
+ const { response, end } = responseStub()
52
+ const next = vi.fn()
53
+
54
+ await handler(requestStub('GET', '/about'), response, next)
55
+ await handler(requestStub('POST', '/api/events'), response, next)
56
+
57
+ expect(next).toHaveBeenCalledTimes(2)
58
+ expect(end).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('supports a custom public agent path', async () => {
62
+ const handler = captureMiddleware(bylineAnalyticsDev({ agentPath: '/first-party.js' }))
63
+ const defaultResponse = responseStub()
64
+ const customResponse = responseStub()
65
+ const next = vi.fn()
66
+
67
+ await handler(requestStub('GET', '/b.js'), defaultResponse.response, next)
68
+ await handler(requestStub('GET', '/first-party.js'), customResponse.response, next)
69
+
70
+ expect(next).toHaveBeenCalledOnce()
71
+ expect(defaultResponse.end).not.toHaveBeenCalled()
72
+ expect(customResponse.end).toHaveBeenCalledWith(ANALYTICS_AGENT_SOURCE)
73
+ })
74
+ })
75
+
76
+ function captureMiddleware(plugin: Plugin): Middleware {
77
+ const handlers: Middleware[] = []
78
+ const configureServer = plugin.configureServer
79
+
80
+ expect(typeof configureServer).toBe('function')
81
+ if (typeof configureServer !== 'function') {
82
+ throw new Error('Expected a Vite configureServer hook')
83
+ }
84
+
85
+ configureServer.call(
86
+ {} as never,
87
+ {
88
+ middlewares: {
89
+ use(handler: Middleware) {
90
+ handlers.push(handler)
91
+ },
92
+ },
93
+ } as never
94
+ )
95
+
96
+ const handler = handlers[0]
97
+ expect(handler).toBeDefined()
98
+ if (handler == null) throw new Error('Expected a Vite middleware')
99
+ return handler
100
+ }
101
+
102
+ function requestStub(
103
+ method: string,
104
+ url: string,
105
+ headers: IncomingMessage['headers'] = {}
106
+ ): IncomingMessage {
107
+ return { method, url, headers } as IncomingMessage
108
+ }
109
+
110
+ function responseStub(): {
111
+ response: ServerResponse
112
+ headers: Record<string, string | number | readonly string[]>
113
+ end: ReturnType<typeof vi.fn>
114
+ } {
115
+ const headers: Record<string, string | number | readonly string[]> = {}
116
+ const end = vi.fn()
117
+ const response = {
118
+ statusCode: 0,
119
+ setHeader(name: string, value: string | number | readonly string[]) {
120
+ headers[name] = value
121
+ return response
122
+ },
123
+ end,
124
+ } as unknown as ServerResponse
125
+ return { response, headers, end }
126
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { Plugin } from 'vite'
10
+
11
+ import { serveAnalyticsAgent } from '../integrations/serve-analytics-agent.js'
12
+
13
+ /**
14
+ * Complete the first-party analytics transport during Vite development.
15
+ *
16
+ * Browser script requests carry `Sec-Fetch-Dest: script`, which Vite routes
17
+ * into its module pipeline before TanStack Start can serve the agent route.
18
+ * This plugin serves the agent directly. The application event route remains
19
+ * responsible for its development identity because POST server routes can
20
+ * bypass Vite's ordinary middleware chain. Production continues to use the
21
+ * application-owned agent route.
22
+ */
23
+ export function bylineAnalyticsDev(
24
+ options: {
25
+ /** Public path that serves the standalone agent. */
26
+ agentPath?: string
27
+ } = {}
28
+ ): Plugin {
29
+ const agentPath = options.agentPath ?? '/b.js'
30
+
31
+ return {
32
+ name: 'byline:analytics-dev',
33
+ apply: 'serve',
34
+ configureServer(server) {
35
+ server.middlewares.use(async (request, response, next) => {
36
+ const pathname = request.url?.split('?', 1)[0]
37
+
38
+ if (request.method === 'GET' && pathname === agentPath) {
39
+ try {
40
+ const agentResponse = serveAnalyticsAgent()
41
+ response.statusCode = agentResponse.status
42
+ agentResponse.headers.forEach((value, name) => {
43
+ response.setHeader(name, value)
44
+ })
45
+ response.end(await agentResponse.text())
46
+ } catch (error) {
47
+ next(error)
48
+ }
49
+ return
50
+ }
51
+
52
+ next()
53
+ })
54
+ },
55
+ }
56
+ }
package/src/vite/index.ts CHANGED
@@ -6,4 +6,5 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ export { bylineAnalyticsDev } from './analytics-dev.js'
9
10
  export { bylineClientHookBoundary } from './client-hook-boundary.js'