@igstack/app-catalog-backend-core 6.10.2 → 6.11.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 (104) hide show
  1. package/dist/db/syncAppCatalog.mjs +1 -1
  2. package/dist/generated/prisma/client.d.mts +1 -1
  3. package/dist/generated/prisma/client.mjs.map +1 -1
  4. package/dist/generated/prisma/commonInputTypes.d.mts +33 -3
  5. package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
  6. package/dist/generated/prisma/enums.d.mts +6 -1
  7. package/dist/generated/prisma/enums.d.mts.map +1 -1
  8. package/dist/generated/prisma/internal/class.d.mts +84 -0
  9. package/dist/generated/prisma/internal/class.d.mts.map +1 -1
  10. package/dist/generated/prisma/internal/class.mjs +4 -4
  11. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  12. package/dist/generated/prisma/internal/prismaNamespace.d.mts +617 -4
  13. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  14. package/dist/generated/prisma/models/DbCommunityTool.d.mts +2852 -0
  15. package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -0
  16. package/dist/generated/prisma/models/DbCommunityToolLike.d.mts +1102 -0
  17. package/dist/generated/prisma/models/DbCommunityToolLike.d.mts.map +1 -0
  18. package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts +1034 -0
  19. package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts.map +1 -0
  20. package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts +1034 -0
  21. package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts.map +1 -0
  22. package/dist/generated/prisma/models/DbCommunityToolPin.d.mts +1110 -0
  23. package/dist/generated/prisma/models/DbCommunityToolPin.d.mts.map +1 -0
  24. package/dist/generated/prisma/models/DbCommunityToolTag.d.mts +1073 -0
  25. package/dist/generated/prisma/models/DbCommunityToolTag.d.mts.map +1 -0
  26. package/dist/generated/prisma/models/DbCommunityToolView.d.mts +1149 -0
  27. package/dist/generated/prisma/models/DbCommunityToolView.d.mts.map +1 -0
  28. package/dist/generated/prisma/models/DbFeedback.d.mts +361 -2
  29. package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -1
  30. package/dist/generated/prisma/models.d.mts +9 -2
  31. package/dist/index.d.mts +5 -2
  32. package/dist/index.mjs +3 -1
  33. package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
  34. package/dist/middleware/createAcMiddleware.mjs +15 -1
  35. package/dist/middleware/createAcMiddleware.mjs.map +1 -1
  36. package/dist/middleware/database.d.mts +1 -1
  37. package/dist/middleware/types.d.mts +20 -1
  38. package/dist/middleware/types.d.mts.map +1 -1
  39. package/dist/modules/communityTools/capture.d.mts +27 -0
  40. package/dist/modules/communityTools/capture.d.mts.map +1 -0
  41. package/dist/modules/communityTools/capture.mjs +192 -0
  42. package/dist/modules/communityTools/capture.mjs.map +1 -0
  43. package/dist/modules/communityTools/healthJob.mjs +132 -0
  44. package/dist/modules/communityTools/healthJob.mjs.map +1 -0
  45. package/dist/modules/communityTools/router.d.mts +150 -0
  46. package/dist/modules/communityTools/router.d.mts.map +1 -0
  47. package/dist/modules/communityTools/router.mjs +91 -0
  48. package/dist/modules/communityTools/router.mjs.map +1 -0
  49. package/dist/modules/communityTools/service.mjs +252 -0
  50. package/dist/modules/communityTools/service.mjs.map +1 -0
  51. package/dist/modules/communityTools/toView.mjs +81 -0
  52. package/dist/modules/communityTools/toView.mjs.map +1 -0
  53. package/dist/modules/communityTools/writeService.d.mts +55 -0
  54. package/dist/modules/communityTools/writeService.d.mts.map +1 -0
  55. package/dist/modules/communityTools/writeService.mjs +280 -0
  56. package/dist/modules/communityTools/writeService.mjs.map +1 -0
  57. package/dist/modules/feedback/feedbackRouter.d.mts +3 -0
  58. package/dist/modules/feedback/feedbackRouter.mjs +6 -2
  59. package/dist/modules/feedback/feedbackRouter.mjs.map +1 -1
  60. package/dist/modules/feedback/service.d.mts +3 -0
  61. package/dist/modules/feedback/service.d.mts.map +1 -1
  62. package/dist/modules/feedback/service.mjs +50 -17
  63. package/dist/modules/feedback/service.mjs.map +1 -1
  64. package/dist/server/controller.d.mts +143 -0
  65. package/dist/server/controller.d.mts.map +1 -1
  66. package/dist/server/controller.mjs +2 -0
  67. package/dist/server/controller.mjs.map +1 -1
  68. package/dist/server/trpcSetup.mjs +1 -1
  69. package/package.json +12 -5
  70. package/prisma/schema.prisma +161 -0
  71. package/src/__tests__/communityTools.test.ts +781 -0
  72. package/src/__tests__/communityToolsCapture.test.ts +319 -0
  73. package/src/__tests__/communityToolsHealth.test.ts +154 -0
  74. package/src/__tests__/communityToolsRouter.test.ts +58 -0
  75. package/src/__tests__/communityToolsWrite.test.ts +561 -0
  76. package/src/__tests__/feedbackService.test.ts +160 -0
  77. package/src/generated/prisma/browser.ts +35 -0
  78. package/src/generated/prisma/client.ts +35 -0
  79. package/src/generated/prisma/commonInputTypes.ts +34 -0
  80. package/src/generated/prisma/enums.ts +8 -0
  81. package/src/generated/prisma/internal/class.ts +74 -4
  82. package/src/generated/prisma/internal/prismaNamespace.ts +635 -2
  83. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +95 -1
  84. package/src/generated/prisma/models/DbCommunityTool.ts +3135 -0
  85. package/src/generated/prisma/models/DbCommunityToolLike.ts +1313 -0
  86. package/src/generated/prisma/models/DbCommunityToolNamedOwner.ts +1243 -0
  87. package/src/generated/prisma/models/DbCommunityToolOwner.ts +1243 -0
  88. package/src/generated/prisma/models/DbCommunityToolPin.ts +1325 -0
  89. package/src/generated/prisma/models/DbCommunityToolTag.ts +1283 -0
  90. package/src/generated/prisma/models/DbCommunityToolView.ts +1361 -0
  91. package/src/generated/prisma/models/DbFeedback.ts +379 -1
  92. package/src/generated/prisma/models.ts +7 -0
  93. package/src/index.ts +16 -0
  94. package/src/middleware/createAcMiddleware.ts +20 -0
  95. package/src/middleware/types.ts +21 -0
  96. package/src/modules/communityTools/capture.ts +332 -0
  97. package/src/modules/communityTools/healthJob.ts +191 -0
  98. package/src/modules/communityTools/router.ts +172 -0
  99. package/src/modules/communityTools/service.ts +380 -0
  100. package/src/modules/communityTools/toView.ts +112 -0
  101. package/src/modules/communityTools/writeService.ts +408 -0
  102. package/src/modules/feedback/feedbackRouter.ts +20 -6
  103. package/src/modules/feedback/service.ts +68 -12
  104. package/src/server/controller.ts +8 -1
@@ -23,4 +23,11 @@ export type * from './models/DbAppTagDefinition'
23
23
  export type * from './models/DbAsset'
24
24
  export type * from './models/Source'
25
25
  export type * from './models/SourceReference'
26
+ export type * from './models/DbCommunityTool'
27
+ export type * from './models/DbCommunityToolOwner'
28
+ export type * from './models/DbCommunityToolLike'
29
+ export type * from './models/DbCommunityToolNamedOwner'
30
+ export type * from './models/DbCommunityToolTag'
31
+ export type * from './models/DbCommunityToolView'
32
+ export type * from './models/DbCommunityToolPin'
26
33
  export type * from './commonInputTypes'
package/src/index.ts CHANGED
@@ -39,6 +39,22 @@ export {
39
39
  type FeedbackList,
40
40
  type FeedbackView,
41
41
  } from './modules/feedback/service'
42
+
43
+ // Community Tools
44
+ // ToolInput and Writer are part of the router's inferred type, so they must be
45
+ // nameable from outside the package or every consumer's trpc hooks become unportable.
46
+ export { createCommunityToolsRouter } from './modules/communityTools/router'
47
+ export {
48
+ toolInputSchema,
49
+ type ToolInput,
50
+ type ToolPreview,
51
+ type Writer,
52
+ } from './modules/communityTools/writeService'
53
+ export type { CommunityToolsOptions } from './middleware/types'
54
+ export type {
55
+ CaptureResult,
56
+ ScreenshotCapturer,
57
+ } from './modules/communityTools/capture'
42
58
  // The identity every surface shares — rows store `hash`, clients may hold `publicId`,
43
59
  // and `visitorFromSeed` is how a test or a dev session becomes a chosen visitor.
44
60
  export {
@@ -13,6 +13,8 @@ import { createAcTrpcContext } from '../server/acTrpcContext'
13
13
  import { createAuth } from '../modules/auth/auth'
14
14
  import { createMockUserFromDevConfig } from '../modules/auth/devMockUserUtils'
15
15
  import { resolveActor } from '../modules/visitor/visitorIdentity'
16
+ import { createPlaywrightCapturer } from '../modules/communityTools/capture'
17
+ import { startHealthJob } from '../modules/communityTools/healthJob'
16
18
 
17
19
  export async function createAcMiddleware(
18
20
  options: AcMiddlewareOptions,
@@ -29,11 +31,28 @@ export async function createAcMiddleware(
29
31
  // Create auth instance
30
32
  const auth = createAuth(options.auth.betterAuthOptions)
31
33
 
34
+ // Resolve the default capturer once, so the router and the health job share it
35
+ const communityTools = options.communityTools && {
36
+ ...options.communityTools,
37
+ capturer:
38
+ options.communityTools.capturer === undefined
39
+ ? createPlaywrightCapturer()
40
+ : options.communityTools.capturer,
41
+ }
42
+
32
43
  // Create tRPC router
33
44
  const trpcRouter = createTrpcRouter(auth, {
34
45
  devLoginEnabled: !!options.auth.devMockUser,
46
+ communityTools,
35
47
  })
36
48
 
49
+ const stopHealthJob = communityTools?.enabled
50
+ ? startHealthJob({
51
+ captureHosts: communityTools.captureHosts ?? [],
52
+ capturer: communityTools.capturer,
53
+ })
54
+ : undefined
55
+
37
56
  // Normalize backend provider to async factory function
38
57
  const resolveBackend = createBackendResolver(options.backend)
39
58
 
@@ -140,6 +159,7 @@ export async function createAcMiddleware(
140
159
  if (options.hooks?.onDatabaseDisconnecting) {
141
160
  await options.hooks.onDatabaseDisconnecting()
142
161
  }
162
+ stopHealthJob?.()
143
163
  await dbManager.disconnect()
144
164
  },
145
165
 
@@ -5,6 +5,7 @@ import type { Session, User } from 'better-auth/types'
5
5
  import type { AppCatalogCompanySpecificBackend } from '../types/backend/companySpecificBackend'
6
6
  import type { BetterAuth } from '../modules/auth/auth'
7
7
  import type { TRPCRouter } from '../server/controller'
8
+ import type { ScreenshotCapturer } from '../modules/communityTools/capture'
8
9
 
9
10
  /**
10
11
  * Database connection configuration.
@@ -171,6 +172,26 @@ export interface AcMiddlewareOptions {
171
172
  * customScripts: ['/assets-static/analytics/analytics.js']
172
173
  */
173
174
  customScripts?: string[]
175
+
176
+ /** Community tools configuration (optional) */
177
+ communityTools?: CommunityToolsOptions
178
+ }
179
+
180
+ /**
181
+ * Community tools feature configuration.
182
+ */
183
+ export interface CommunityToolsOptions {
184
+ enabled: boolean
185
+ /**
186
+ * Host suffixes the headless browser may visit, e.g. `['example.com']` for
187
+ * `example.com` and its subdomains. Empty by default: no screenshots, no health check.
188
+ */
189
+ captureHosts?: string[]
190
+ /**
191
+ * Overrides the default `playwright-core` capturer. `null` disables capture; omitted
192
+ * means use playwright-core when it is installed.
193
+ */
194
+ capturer?: ScreenshotCapturer | null
174
195
  }
175
196
 
176
197
  /**
@@ -0,0 +1,332 @@
1
+ import { createRequire } from 'node:module'
2
+ import { isCaptureAllowed } from '@igstack/app-catalog-shared-core'
3
+
4
+ export type CaptureResult =
5
+ | {
6
+ ok: true
7
+ png: Buffer
8
+ title: string | null
9
+ favicon: Buffer | null
10
+ /** The page's `Last-Modified`: a static host sets it on each deploy. */
11
+ lastModified: Date | null
12
+ }
13
+ | { ok: false; error: string }
14
+
15
+ /**
16
+ * Visits a page and photographs it, reaching only `allowedHosts`.
17
+ *
18
+ * Resolves `{ ok: false }` when the page itself failed (no response, an error status, a
19
+ * timeout, a redirect off the list) — that is the tool's health. Throws when the
20
+ * browser could not be started, which is ours: the health job must not mark every tool
21
+ * broken because Chromium is missing on one pod.
22
+ */
23
+ export interface ScreenshotCapturer {
24
+ capture: (url: string, allowedHosts: string[]) => Promise<CaptureResult>
25
+ /** Captures queued and not started yet; a capturer without a queue leaves it out. */
26
+ waiting?: () => number
27
+ }
28
+
29
+ /** The part of a playwright `Page` a capture uses, so it can be tested without a browser. */
30
+ export interface CapturePage {
31
+ setDefaultTimeout: (ms: number) => void
32
+ goto: (
33
+ url: string,
34
+ options: { timeout: number; waitUntil: 'load' },
35
+ ) => Promise<{
36
+ ok: () => boolean
37
+ status: () => number
38
+ headers: () => Record<string, string>
39
+ } | null>
40
+ waitForLoadState: (
41
+ state: 'networkidle',
42
+ options: { timeout: number },
43
+ ) => Promise<void>
44
+ waitForTimeout: (ms: number) => Promise<void>
45
+ url: () => string
46
+ screenshot: (options: { type: 'png' }) => Promise<Buffer>
47
+ title: () => Promise<string>
48
+ evaluate: (expression: string) => Promise<unknown>
49
+ request: {
50
+ get: (
51
+ url: string,
52
+ options: { timeout: number; maxRedirects: number },
53
+ ) => Promise<{ ok: () => boolean; body: () => Promise<Buffer> }>
54
+ }
55
+ }
56
+
57
+ const VIEWPORT = { width: 1280, height: 800 }
58
+ const TIMEOUT_MS = 20_000
59
+ /** After the network goes quiet, for the page to render what it fetched. */
60
+ const SETTLE_MS = 1_500
61
+
62
+ /**
63
+ * Whether the browser may load this URL: navigations, redirects and subresources alike.
64
+ * Inline `data:`/`blob:` URLs reach no host, and pages need them to render.
65
+ */
66
+ export function allowRequest(url: string, allowedHosts: string[]): boolean {
67
+ return (
68
+ url.startsWith('data:') ||
69
+ url.startsWith('blob:') ||
70
+ isCaptureAllowed(url, allowedHosts)
71
+ )
72
+ }
73
+
74
+ const MAX_REDIRECTS = 5
75
+ const HOP_TIMEOUT_MS = 10_000
76
+ /** Hops a browser repeats as a bodyless GET; 307 and 308 keep the method and body. */
77
+ const REDIRECTS_TO_GET = [301, 302, 303]
78
+
79
+ /** `same` repeats the original method and body; `get` is a bodyless GET. */
80
+ export type HopMode = 'same' | 'get'
81
+
82
+ /**
83
+ * Fetches a URL, following redirects only while every hop stays on the allowed hosts.
84
+ * Null when a hop leaves them, or after too many hops; the off-list hop is never requested.
85
+ *
86
+ * Done by hand because Chromium follows a redirect, even one answered through
87
+ * `route.fulfill`, without consulting the route again (measured against a real browser).
88
+ */
89
+ export async function followWithinHosts<
90
+ TResponse extends {
91
+ status: () => number
92
+ headers: () => Record<string, string>
93
+ },
94
+ >(
95
+ fetch: (url: string, mode: HopMode) => Promise<TResponse>,
96
+ url: string,
97
+ allowedHosts: string[],
98
+ ): Promise<TResponse | null> {
99
+ let hop = url
100
+ let mode: HopMode = 'same'
101
+ for (let hops = 0; ; hops++) {
102
+ const response = await fetch(hop, mode)
103
+ const location = response.headers()['location']
104
+ const status = response.status()
105
+ if (status < 300 || status >= 400 || !location) return response
106
+ if (REDIRECTS_TO_GET.includes(status)) mode = 'get'
107
+ hop = new URL(location, hop).href
108
+ if (hops >= MAX_REDIRECTS || !isCaptureAllowed(hop, allowedHosts)) {
109
+ return null
110
+ }
111
+ }
112
+ }
113
+
114
+ /** The parts of a playwright `Route` the fence uses, so it can be tested without a browser. */
115
+ export interface FenceRoute<TResponse> {
116
+ url: string
117
+ abort: (code: 'blockedbyclient' | 'failed') => Promise<void>
118
+ continue: () => Promise<void>
119
+ fetch: (url: string, mode: HopMode) => Promise<TResponse>
120
+ fulfill: (response: TResponse) => Promise<void>
121
+ }
122
+
123
+ /**
124
+ * Answers one browser request: off-list is aborted, on-list is fetched hop by hop.
125
+ *
126
+ * Never rejects. A rejection in a route handler surfaces as an unhandledRejection and
127
+ * exits Node (measured), so one dead image on a tool's page would take the pod down.
128
+ */
129
+ export async function fenceRoute<
130
+ TResponse extends {
131
+ status: () => number
132
+ headers: () => Record<string, string>
133
+ },
134
+ >(route: FenceRoute<TResponse>, allowedHosts: string[]): Promise<void> {
135
+ try {
136
+ if (!allowRequest(route.url, allowedHosts)) {
137
+ await route.abort('blockedbyclient')
138
+ } else if (route.url.startsWith('data:') || route.url.startsWith('blob:')) {
139
+ await route.continue()
140
+ } else {
141
+ const response = await followWithinHosts(
142
+ route.fetch,
143
+ route.url,
144
+ allowedHosts,
145
+ )
146
+ if (response) await route.fulfill(response)
147
+ else await route.abort('blockedbyclient')
148
+ }
149
+ } catch {
150
+ await route.abort('failed').catch(() => undefined)
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Chromium flags that fence the network stack itself, below anything a page can reach:
156
+ * every name but the capture hosts fails to resolve (IP literals included), which also
157
+ * covers worker fetches and sockets that `route` never sees; WebRTC gets no direct UDP.
158
+ * A host that is not a plain name is dropped, so config cannot inject resolver rules.
159
+ */
160
+ export function chromiumFenceArgs(allowedHosts: string[]): string[] {
161
+ const rules = [
162
+ 'MAP * ~NOTFOUND',
163
+ ...allowedHosts
164
+ .filter((host) => /^[a-z0-9.-]+$/i.test(host))
165
+ .flatMap((host) => [`EXCLUDE *.${host}`, `EXCLUDE ${host}`]),
166
+ ]
167
+ return [
168
+ `--host-resolver-rules=${rules.join(', ')}`,
169
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
170
+ '--webrtc-ip-handling-policy=disable_non_proxied_udp',
171
+ ]
172
+ }
173
+
174
+ /** Navigates an already-fenced page and collects the screenshot, title and favicon. */
175
+ export async function capturePage(
176
+ page: CapturePage,
177
+ url: string,
178
+ allowedHosts: string[],
179
+ ): Promise<CaptureResult> {
180
+ page.setDefaultTimeout(TIMEOUT_MS)
181
+ const deadline = Date.now() + TIMEOUT_MS
182
+ const response = await page.goto(url, {
183
+ timeout: TIMEOUT_MS,
184
+ waitUntil: 'load',
185
+ })
186
+ // The route fence aborts an off-list redirect, but the result must not depend on it.
187
+ if (!isCaptureAllowed(page.url(), allowedHosts)) {
188
+ return { ok: false, error: 'redirected off the allowed hosts' }
189
+ }
190
+ if (!response || !response.ok()) {
191
+ return {
192
+ ok: false,
193
+ error: response ? `HTTP ${response.status()}` : 'No response',
194
+ }
195
+ }
196
+ // `load` is only the HTML and its assets: a page that fetches its data after
197
+ // that would be photographed saying "Loading…". So wait for the network to go
198
+ // quiet, then a moment for it to render, all inside the one timeout. A page
199
+ // that never goes quiet (polling, a live feed) is photographed as it is.
200
+ const idleBudget = deadline - Date.now() - SETTLE_MS
201
+ if (idleBudget > 0) {
202
+ await page
203
+ .waitForLoadState('networkidle', { timeout: idleBudget })
204
+ .catch(() => undefined)
205
+ }
206
+ const settle = Math.min(SETTLE_MS, deadline - Date.now())
207
+ if (settle > 0) await page.waitForTimeout(settle)
208
+ const png = await page.screenshot({ type: 'png' })
209
+ const title = (await page.title()).trim() || null
210
+
211
+ // A string, so this file needs no DOM types.
212
+ const iconHref = await page.evaluate(
213
+ `document.querySelector('link[rel~="icon"]')?.href ?? null`,
214
+ )
215
+ const iconUrl =
216
+ typeof iconHref === 'string'
217
+ ? iconHref
218
+ : new URL('/favicon.ico', page.url()).href
219
+ let favicon: Buffer | null = null
220
+ // `page.request` bypasses the route fence, so the href the page chose is checked here,
221
+ // and a redirect is not followed anywhere.
222
+ if (isCaptureAllowed(iconUrl, allowedHosts)) {
223
+ try {
224
+ const icon = await page.request.get(iconUrl, {
225
+ timeout: 5_000,
226
+ maxRedirects: 0,
227
+ })
228
+ if (icon.ok()) favicon = await icon.body()
229
+ } catch {
230
+ // No favicon is fine: the card falls back to a monogram.
231
+ }
232
+ }
233
+
234
+ return {
235
+ ok: true,
236
+ png,
237
+ title,
238
+ favicon,
239
+ lastModified: httpDate(response.headers()['last-modified']),
240
+ }
241
+ }
242
+
243
+ /** An HTTP date header as a Date; null when absent or unreadable. */
244
+ export function httpDate(value: string | undefined): Date | null {
245
+ if (!value) return null
246
+ const date = new Date(value)
247
+ return Number.isNaN(date.getTime()) ? null : date
248
+ }
249
+
250
+ /** Runs captures one at a time, so concurrent previews cannot start a browser each. */
251
+ export function oneAtATime(
252
+ capture: ScreenshotCapturer['capture'],
253
+ ): Required<ScreenshotCapturer> {
254
+ let queue: Promise<unknown> = Promise.resolve()
255
+ let waiting = 0
256
+ return {
257
+ waiting: () => waiting,
258
+ capture: (url, allowedHosts) => {
259
+ waiting++
260
+ const run = queue.then(() => {
261
+ waiting--
262
+ return capture(url, allowedHosts)
263
+ })
264
+ queue = run.catch(() => undefined)
265
+ return run
266
+ },
267
+ }
268
+ }
269
+
270
+ /**
271
+ * A capturer backed by the optional `playwright-core` peer, or null when it is not
272
+ * installed — the feature then works without screenshots.
273
+ */
274
+ export function createPlaywrightCapturer(): ScreenshotCapturer | null {
275
+ try {
276
+ createRequire(import.meta.url).resolve('playwright-core')
277
+ } catch {
278
+ return null
279
+ }
280
+
281
+ return oneAtATime(async (url, allowedHosts) => {
282
+ const { chromium } = await import('playwright-core')
283
+ // Launched per capture because the fence flags depend on the hosts; the queue
284
+ // keeps that to one browser at a time.
285
+ const browser = await chromium.launch({
286
+ executablePath:
287
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
288
+ args: chromiumFenceArgs(allowedHosts),
289
+ })
290
+ try {
291
+ // A service worker could answer requests the route never sees.
292
+ const context = await browser.newContext({
293
+ viewport: VIEWPORT,
294
+ serviceWorkers: 'block',
295
+ })
296
+ // Second layer under the resolver fence: per-request and per-hop checks.
297
+ await context.route('**/*', (route) =>
298
+ fenceRoute(
299
+ {
300
+ url: route.request().url(),
301
+ abort: (code) => route.abort(code),
302
+ continue: () => route.continue(),
303
+ fetch: (hop, mode) =>
304
+ mode === 'get'
305
+ ? context.request.get(hop, {
306
+ maxRedirects: 0,
307
+ timeout: HOP_TIMEOUT_MS,
308
+ })
309
+ : route.fetch({
310
+ url: hop,
311
+ maxRedirects: 0,
312
+ timeout: HOP_TIMEOUT_MS,
313
+ }),
314
+ fulfill: (response) => route.fulfill({ response }),
315
+ },
316
+ allowedHosts,
317
+ ),
318
+ )
319
+ // A screenshot needs no live socket, and `route` does not see them.
320
+ await context.routeWebSocket(/.*/, (ws) => ws.close())
321
+ const page = await context.newPage()
322
+ return await capturePage(page, url, allowedHosts)
323
+ } catch (error) {
324
+ return {
325
+ ok: false,
326
+ error: error instanceof Error ? error.message : String(error),
327
+ }
328
+ } finally {
329
+ await browser.close()
330
+ }
331
+ })
332
+ }
@@ -0,0 +1,191 @@
1
+ import {
2
+ isCaptureAllowed,
3
+ nextLifecycle,
4
+ } from '@igstack/app-catalog-shared-core'
5
+ import { getDbClient } from '../../db/client'
6
+ import { resizeImage } from '../assets/assetUtils'
7
+ import { upsertAsset } from '../assets/upsertAsset'
8
+ import type { CaptureResult, ScreenshotCapturer } from './capture'
9
+
10
+ export interface CaptureDeps {
11
+ captureHosts: string[]
12
+ capturer: ScreenshotCapturer | null
13
+ }
14
+
15
+ const MINUTE_MS = 60 * 1000
16
+ const HOUR_MS = 60 * MINUTE_MS
17
+ const DAY_MS = 24 * HOUR_MS
18
+ /** Wall time one run may spend; what is left is due again on the next tick. */
19
+ const RUN_BUDGET_MS = 40 * MINUTE_MS
20
+ /** Past the budget plus one capture, and short of the next tick. */
21
+ const RUN_TRANSACTION_TIMEOUT_MS = 55 * MINUTE_MS
22
+ const FIRST_RUN_DELAY_MS = MINUTE_MS
23
+
24
+ interface CheckableTool {
25
+ id: string
26
+ slug: string
27
+ url: string
28
+ failingSince: Date | null
29
+ archivedAt: Date | null
30
+ archivedBy: string | null
31
+ }
32
+
33
+ /** Stores the screenshot and, when it decodes, the favicon as assets. */
34
+ async function storeCapture(
35
+ slug: string,
36
+ result: Extract<CaptureResult, { ok: true }>,
37
+ now: Date,
38
+ ) {
39
+ const prisma = getDbClient()
40
+ const screenshotAssetId = await upsertAsset({
41
+ prisma,
42
+ buffer: result.png,
43
+ name: `community-${slug}-screenshot`,
44
+ originalFilename: `community-${slug}-screenshot.png`,
45
+ assetType: 'screenshot',
46
+ })
47
+
48
+ let faviconAssetId: string | undefined
49
+ if (result.favicon) {
50
+ try {
51
+ // ponytail: sharp cannot decode .ico, so those tools get no favicon; add an ico decoder if it matters
52
+ const png = await resizeImage(result.favicon, 64, 64, 'png')
53
+ faviconAssetId = await upsertAsset({
54
+ prisma,
55
+ buffer: png,
56
+ name: `community-${slug}-icon`,
57
+ originalFilename: `community-${slug}-icon.png`,
58
+ assetType: 'icon',
59
+ })
60
+ } catch {
61
+ // An undecodable favicon leaves the previous one, or none.
62
+ }
63
+ }
64
+
65
+ return {
66
+ screenshotAssetId,
67
+ ...(faviconAssetId && { faviconAssetId }),
68
+ capturedAt: now,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Visits one tool and records the result. Hosts outside the configured suffixes, and a
74
+ * deployment without a capturer, are left untouched: no check means no state change.
75
+ */
76
+ export async function checkTool(
77
+ tool: CheckableTool,
78
+ deps: CaptureDeps,
79
+ now: Date,
80
+ ): Promise<void> {
81
+ if (!deps.capturer || !isCaptureAllowed(tool.url, deps.captureHosts)) return
82
+
83
+ let result: CaptureResult
84
+ try {
85
+ result = await deps.capturer.capture(tool.url, deps.captureHosts)
86
+ } catch (error) {
87
+ // A browser that would not start says nothing about the page, so the
88
+ // lifecycle stays as it was. The attempt is still recorded: without it a
89
+ // new tool reads "capture pending" forever and its open card keeps asking.
90
+ await getDbClient().dbCommunityTool.update({
91
+ where: { id: tool.id },
92
+ data: { lastCheckAt: now },
93
+ })
94
+ throw error
95
+ }
96
+ await getDbClient().dbCommunityTool.update({
97
+ where: { id: tool.id },
98
+ data: {
99
+ lastCheckAt: now,
100
+ ...nextLifecycle(tool, result.ok, now),
101
+ ...(result.ok && (await storeCapture(tool.slug, result, now))),
102
+ // A visit without the header leaves the date it last had.
103
+ ...(result.ok &&
104
+ result.lastModified && {
105
+ siteUpdatedAt: result.lastModified,
106
+ }),
107
+ },
108
+ })
109
+ }
110
+
111
+ /**
112
+ * One pass over every tool not checked in the last day.
113
+ *
114
+ * The transaction-scoped lock rather than `pg_try_advisory_lock`: a session lock is
115
+ * held by whichever pooled connection ran the query, and the unlock may run on another,
116
+ * leaving the lock stuck forever. The transaction pins one connection and releases the
117
+ * lock when it ends, even if the pod dies mid-run.
118
+ *
119
+ * ponytail: one transaction holds the lock for the whole run, so a run is capped at
120
+ * RUN_BUDGET_MS (about 90 tools at 25 s each); per-tool short transactions or a job
121
+ * queue if the catalog outgrows that.
122
+ */
123
+ export async function runHealthCheckOnce(
124
+ deps: CaptureDeps,
125
+ now: Date,
126
+ clock: () => number = Date.now,
127
+ ): Promise<void> {
128
+ const db = getDbClient()
129
+ await db.$transaction(
130
+ async (tx) => {
131
+ const [row] = await tx.$queryRaw<{ locked: boolean }[]>`
132
+ SELECT pg_try_advisory_xact_lock(hashtext('community-tools-health')) AS locked`
133
+ if (!row?.locked) return
134
+
135
+ const cutoff = now.getTime() - DAY_MS
136
+ // ponytail: reads every tool each hour; move the filters into the query if the table grows large
137
+ const tools = await db.dbCommunityTool.findMany({
138
+ select: {
139
+ id: true,
140
+ slug: true,
141
+ url: true,
142
+ lastCheckAt: true,
143
+ failingSince: true,
144
+ archivedAt: true,
145
+ archivedBy: true,
146
+ },
147
+ })
148
+ const due = tools.filter(
149
+ (t) =>
150
+ !(t.archivedAt && t.archivedBy !== 'auto') &&
151
+ (!t.lastCheckAt || t.lastCheckAt.getTime() < cutoff),
152
+ )
153
+
154
+ const startedAt = clock()
155
+ for (const tool of due) {
156
+ if (clock() - startedAt >= RUN_BUDGET_MS) break
157
+ try {
158
+ await checkTool(tool, deps, now)
159
+ } catch (error) {
160
+ console.error(`[communityTools] health check of ${tool.slug}:`, error)
161
+ }
162
+ }
163
+ },
164
+ { maxWait: 10_000, timeout: RUN_TRANSACTION_TIMEOUT_MS },
165
+ )
166
+ }
167
+
168
+ /**
169
+ * Starts the pass a minute after boot, then hourly: a pod restarted more often than
170
+ * hourly would otherwise never check. The lock keeps pods from overlapping.
171
+ * Returns a stop function.
172
+ */
173
+ export function startHealthJob(
174
+ deps: CaptureDeps,
175
+ now: () => Date = () => new Date(),
176
+ ): () => void {
177
+ const run = () => {
178
+ runHealthCheckOnce(deps, now()).catch((error: unknown) => {
179
+ console.error('[communityTools] health run failed:', error)
180
+ })
181
+ }
182
+ const first = setTimeout(run, FIRST_RUN_DELAY_MS)
183
+ const timer = setInterval(run, HOUR_MS)
184
+ // Neither keeps the process, or a test runner, alive.
185
+ first.unref()
186
+ timer.unref()
187
+ return () => {
188
+ clearTimeout(first)
189
+ clearInterval(timer)
190
+ }
191
+ }