@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.
- package/dist/db/syncAppCatalog.mjs +1 -1
- package/dist/generated/prisma/client.d.mts +1 -1
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/commonInputTypes.d.mts +33 -3
- package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
- package/dist/generated/prisma/enums.d.mts +6 -1
- package/dist/generated/prisma/enums.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +84 -0
- package/dist/generated/prisma/internal/class.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +617 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityTool.d.mts +2852 -0
- package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts +1102 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts +1110 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts +1073 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts +1149 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbFeedback.d.mts +361 -2
- package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -1
- package/dist/generated/prisma/models.d.mts +9 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -1
- package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
- package/dist/middleware/createAcMiddleware.mjs +15 -1
- package/dist/middleware/createAcMiddleware.mjs.map +1 -1
- package/dist/middleware/database.d.mts +1 -1
- package/dist/middleware/types.d.mts +20 -1
- package/dist/middleware/types.d.mts.map +1 -1
- package/dist/modules/communityTools/capture.d.mts +27 -0
- package/dist/modules/communityTools/capture.d.mts.map +1 -0
- package/dist/modules/communityTools/capture.mjs +192 -0
- package/dist/modules/communityTools/capture.mjs.map +1 -0
- package/dist/modules/communityTools/healthJob.mjs +132 -0
- package/dist/modules/communityTools/healthJob.mjs.map +1 -0
- package/dist/modules/communityTools/router.d.mts +150 -0
- package/dist/modules/communityTools/router.d.mts.map +1 -0
- package/dist/modules/communityTools/router.mjs +91 -0
- package/dist/modules/communityTools/router.mjs.map +1 -0
- package/dist/modules/communityTools/service.mjs +252 -0
- package/dist/modules/communityTools/service.mjs.map +1 -0
- package/dist/modules/communityTools/toView.mjs +81 -0
- package/dist/modules/communityTools/toView.mjs.map +1 -0
- package/dist/modules/communityTools/writeService.d.mts +55 -0
- package/dist/modules/communityTools/writeService.d.mts.map +1 -0
- package/dist/modules/communityTools/writeService.mjs +280 -0
- package/dist/modules/communityTools/writeService.mjs.map +1 -0
- package/dist/modules/feedback/feedbackRouter.d.mts +3 -0
- package/dist/modules/feedback/feedbackRouter.mjs +6 -2
- package/dist/modules/feedback/feedbackRouter.mjs.map +1 -1
- package/dist/modules/feedback/service.d.mts +3 -0
- package/dist/modules/feedback/service.d.mts.map +1 -1
- package/dist/modules/feedback/service.mjs +50 -17
- package/dist/modules/feedback/service.mjs.map +1 -1
- package/dist/server/controller.d.mts +143 -0
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +2 -0
- package/dist/server/controller.mjs.map +1 -1
- package/dist/server/trpcSetup.mjs +1 -1
- package/package.json +12 -5
- package/prisma/schema.prisma +161 -0
- package/src/__tests__/communityTools.test.ts +781 -0
- package/src/__tests__/communityToolsCapture.test.ts +319 -0
- package/src/__tests__/communityToolsHealth.test.ts +154 -0
- package/src/__tests__/communityToolsRouter.test.ts +58 -0
- package/src/__tests__/communityToolsWrite.test.ts +561 -0
- package/src/__tests__/feedbackService.test.ts +160 -0
- package/src/generated/prisma/browser.ts +35 -0
- package/src/generated/prisma/client.ts +35 -0
- package/src/generated/prisma/commonInputTypes.ts +34 -0
- package/src/generated/prisma/enums.ts +8 -0
- package/src/generated/prisma/internal/class.ts +74 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +635 -2
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +95 -1
- package/src/generated/prisma/models/DbCommunityTool.ts +3135 -0
- package/src/generated/prisma/models/DbCommunityToolLike.ts +1313 -0
- package/src/generated/prisma/models/DbCommunityToolNamedOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolPin.ts +1325 -0
- package/src/generated/prisma/models/DbCommunityToolTag.ts +1283 -0
- package/src/generated/prisma/models/DbCommunityToolView.ts +1361 -0
- package/src/generated/prisma/models/DbFeedback.ts +379 -1
- package/src/generated/prisma/models.ts +7 -0
- package/src/index.ts +16 -0
- package/src/middleware/createAcMiddleware.ts +20 -0
- package/src/middleware/types.ts +21 -0
- package/src/modules/communityTools/capture.ts +332 -0
- package/src/modules/communityTools/healthJob.ts +191 -0
- package/src/modules/communityTools/router.ts +172 -0
- package/src/modules/communityTools/service.ts +380 -0
- package/src/modules/communityTools/toView.ts +112 -0
- package/src/modules/communityTools/writeService.ts +408 -0
- package/src/modules/feedback/feedbackRouter.ts +20 -6
- package/src/modules/feedback/service.ts +68 -12
- 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
|
|
package/src/middleware/types.ts
CHANGED
|
@@ -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
|
+
}
|