@aglyn/tenant-data-admin 1.0.0-beta.222 → 1.0.0-beta.224
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/package.json +7 -7
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +13 -0
- package/src/lib/server/host-visitor-records.js +22 -4
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +6 -3
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +6 -3
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +233 -0
- package/src/lib/server/render-monitor.js +573 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +2 -0
- package/src/lib/server/web-risk.js +18 -21
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +0 -2
- package/src/tenancy.js +0 -2
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
|
|
18
|
+
/**
|
|
19
|
+
* THE RENDER MONITOR (AGL-3568): does a published site still render a page
|
|
20
|
+
* that was not already in a cache?
|
|
21
|
+
*
|
|
22
|
+
* On 2026-10-05 every uncached page render on the published-site runtime
|
|
23
|
+
* hung to the platform's 60 s limit for 28 minutes, and nothing alerted. The
|
|
24
|
+
* three monitors that existed each missed it for its own reason:
|
|
25
|
+
*
|
|
26
|
+
* - the GitHub uptime probe is scheduled every 15 minutes and GitHub ran it
|
|
27
|
+
* four times that day, none of them during the outage;
|
|
28
|
+
* - its page rows fetch `/`, which the ISR cache answers, so a visitor's
|
|
29
|
+
* page that was cached still loaded and the row stayed green;
|
|
30
|
+
* - `/api/health/render/site` runs the page LOADER in-process and never the
|
|
31
|
+
* site layout, which is where the request was stuck.
|
|
32
|
+
*
|
|
33
|
+
* So this fetches pages over HTTP, from the console (a different deployment
|
|
34
|
+
* from the one it watches), on Cloud Scheduler every five minutes, and asks
|
|
35
|
+
* for two pages no cache can answer:
|
|
36
|
+
*
|
|
37
|
+
* - `dynamic` — `/search`, a route every site has that renders on every
|
|
38
|
+
* request (`force-dynamic`) inside the full site layout. A 200 that is a
|
|
39
|
+
* complete document proves the layout and head ran just now.
|
|
40
|
+
* - `isr` — a path nobody has ever requested, under the catch-all page.
|
|
41
|
+
* A never-seen path is the one request the ISR cache cannot answer, so it
|
|
42
|
+
* renders in the same static context every published page renders in,
|
|
43
|
+
* which is the context the 2026-09-09 outage only appeared in. It is a
|
|
44
|
+
* 404 by construction, and the site's own not-found page is drawn inside
|
|
45
|
+
* the same layout, so a complete 404 document is a pass.
|
|
46
|
+
*
|
|
47
|
+
* A site that fails `threshold` runs in a row raises
|
|
48
|
+
* `system.siteRenderFailing` once; the first passing run after that raises
|
|
49
|
+
* `system.siteRenderRecovered` once. The state lives in
|
|
50
|
+
* `operatorHealthState/render-monitor--<host>`, beside every other health
|
|
51
|
+
* check, so Staff → Operator alerts lists it with its status and since when.
|
|
52
|
+
*
|
|
53
|
+
* Nothing here names an Aglyn host for anyone else: the default target is the
|
|
54
|
+
* install's own demonstration site label under its own tenant apex, the
|
|
55
|
+
* convention the middleware and the render canaries already follow. The one
|
|
56
|
+
* exception is `PLATFORM_RENDER_PAGES` below, which applies on Aglyn's own
|
|
57
|
+
* console deployment and nowhere else.
|
|
58
|
+
*
|
|
59
|
+
* ## Real pages, rendered fresh (AGL-3571)
|
|
60
|
+
*
|
|
61
|
+
* The two probes above draw the layout. They do not draw a page BODY, and
|
|
62
|
+
* beta.223 hung only on page bodies with an image in them (an ancestor walk
|
|
63
|
+
* looped on the page root, AGL-3565): `demo` has no images and the not-found
|
|
64
|
+
* page has no body, so this monitor read green through it. So a third probe,
|
|
65
|
+
* `page`, renders a real published page through the full ISR path — through
|
|
66
|
+
* the tenant's `*.vercel.app` production domain (`RENDER_MONITOR_RENDER_ORIGIN`),
|
|
67
|
+
* which honors `?tenantHost=` and sits behind Deployment Protection, naming
|
|
68
|
+
* the site with a spelling no cache holds (`freshHostSpelling`). The cache key
|
|
69
|
+
* includes the `[host]` segment, and `normalizeHostAlias` resolves every
|
|
70
|
+
* spelling to the same site. Without the automation bypass the render origin
|
|
71
|
+
* answers its sign-in redirect, so no `page` probe is made at all.
|
|
72
|
+
*
|
|
73
|
+
* One page per site per run, rotating through that site's list: a platform
|
|
74
|
+
* break fails every page with an image, so every run, and alerts as fast as
|
|
75
|
+
* before; a break on one page alone is seen on its turn.
|
|
76
|
+
*/ /** The state documents' collection: the health checks' own. */ const RENDER_MONITOR_COLLECTION = 'operatorHealthState';
|
|
77
|
+
/** Every render monitor document id starts with this. */ export const RENDER_MONITOR_CHECK_PREFIX = 'render-monitor--';
|
|
78
|
+
/** The beat id `/api/health/crons` reads for the scheduled run. */ export const RENDER_MONITOR_JOB_ID = 'render-monitor';
|
|
79
|
+
/**
|
|
80
|
+
* How long one page fetch may take. Well under the 60 s a platform function
|
|
81
|
+
* may run, so a hung render is graded here as a timeout rather than waiting
|
|
82
|
+
* for the platform's 504, and well under the route's own `maxDuration`.
|
|
83
|
+
*/ export const RENDER_MONITOR_TIMEOUT_MS = 25000;
|
|
84
|
+
/** Failing runs in a row before the alert goes out. */ export const RENDER_MONITOR_DEFAULT_THRESHOLD = 2;
|
|
85
|
+
/**
|
|
86
|
+
* The prefix of the never-requested path the `isr` probe asks for. A
|
|
87
|
+
* lowercase slug on purpose: it must reach the catch-all page like any
|
|
88
|
+
* screen slug would, not a reserved or rewritten route.
|
|
89
|
+
*/ export const RENDER_PROBE_ISR_PREFIX = '/aglyn-render-probe-';
|
|
90
|
+
export const RENDER_PROBES = [
|
|
91
|
+
{
|
|
92
|
+
kind: 'dynamic',
|
|
93
|
+
path: ()=>'/search',
|
|
94
|
+
expectStatus: 200
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
kind: 'isr',
|
|
98
|
+
path: (nonce)=>`${RENDER_PROBE_ISR_PREFIX}${nonce}`,
|
|
99
|
+
expectStatus: 404
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
/** Vercel's challenge interstitial, matched on the body like the front doors. */ const CHECKPOINT_MARKER = /Vercel Security Checkpoint/i;
|
|
103
|
+
/**
|
|
104
|
+
* Cache states that mean the bytes came out of a store. A probe meant to
|
|
105
|
+
* prove a render cannot pass on one of these, whatever the status.
|
|
106
|
+
*/ const CACHED_STATES = new Set([
|
|
107
|
+
'HIT',
|
|
108
|
+
'STALE',
|
|
109
|
+
'PRERENDER'
|
|
110
|
+
]);
|
|
111
|
+
/**
|
|
112
|
+
* Grade one probe response. Pure.
|
|
113
|
+
*
|
|
114
|
+
* The page markers are the front doors' (`tools/scripts/lib/front-door.mjs`)
|
|
115
|
+
* and for their reason: structural, never site copy. `</html>` proves the
|
|
116
|
+
* document finished streaming, `/_next/static/` that our build produced it.
|
|
117
|
+
*/ export function gradeRenderProbe(response, expectStatus) {
|
|
118
|
+
var _response_body, _response_contentType, _response_cacheState;
|
|
119
|
+
const body = (_response_body = response.body) != null ? _response_body : '';
|
|
120
|
+
if (CHECKPOINT_MARKER.test(body)) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
challenged: true,
|
|
124
|
+
code: 'challenged',
|
|
125
|
+
detail: `bot protection answered (HTTP ${response.status}) instead of the site; check AGLYN_PROBE_TOKEN on the console`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (response.status >= 300 && response.status < 400) {
|
|
129
|
+
var _response_location;
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
challenged: false,
|
|
133
|
+
code: 'redirected',
|
|
134
|
+
detail: `redirected to ${(_response_location = response.location) != null ? _response_location : '?'}, so no page was rendered`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (response.status !== expectStatus) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
challenged: false,
|
|
141
|
+
code: `http-${response.status}`,
|
|
142
|
+
detail: `HTTP ${response.status}`
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!/text\/html/i.test((_response_contentType = response.contentType) != null ? _response_contentType : '')) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
challenged: false,
|
|
149
|
+
code: 'not-html',
|
|
150
|
+
detail: `not HTML (${response.contentType || 'no content-type'})`
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (!body.includes('</html>')) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
challenged: false,
|
|
157
|
+
code: 'incomplete',
|
|
158
|
+
detail: 'the document never finished (no </html>)'
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (!body.includes('/_next/static/')) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
challenged: false,
|
|
165
|
+
code: 'not-our-render',
|
|
166
|
+
detail: 'no Next asset references, so the app did not render it'
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const cache = String((_response_cacheState = response.cacheState) != null ? _response_cacheState : '').trim().toUpperCase();
|
|
170
|
+
if (CACHED_STATES.has(cache)) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
challenged: false,
|
|
174
|
+
code: 'served-from-cache',
|
|
175
|
+
detail: `served from cache (${cache}), so it proves no render`
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
challenged: false,
|
|
181
|
+
code: 'ok',
|
|
182
|
+
detail: 'rendered'
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The pages Aglyn's own console renders fresh when `RENDER_MONITOR_PAGES` is
|
|
187
|
+
* unset: the canary's real client pages (`tools/scripts/lib/prod-canary.mjs`
|
|
188
|
+
* `DEFAULT_TENANT_HOSTS`), images, reusable components, repeats and a form
|
|
189
|
+
* between them. Applied ONLY on that console deployment (`VERCEL_PROJECT_ID`),
|
|
190
|
+
* so no other install ever requests them.
|
|
191
|
+
*/ export const PLATFORM_RENDER_PAGES = [
|
|
192
|
+
'ready-to-roll.aglyn.app/',
|
|
193
|
+
'edr-construction.aglyn.app/',
|
|
194
|
+
'edr-construction.aglyn.app/services',
|
|
195
|
+
'edr-construction.aglyn.app/contact'
|
|
196
|
+
].join(' ');
|
|
197
|
+
/** Aglyn's console project on Vercel, the one deployment the list above is for. */ export const PLATFORM_CONSOLE_VERCEL_PROJECT_ID = 'prj_gEzxEXc0Lhs81rmaXIg2a1GbsDfl';
|
|
198
|
+
/**
|
|
199
|
+
* The tenant's production project domain on Vercel: follows every promote and
|
|
200
|
+
* rollback, honors `?tenantHost=`, and is reachable only with the bypass.
|
|
201
|
+
*/ export const PLATFORM_RENDER_ORIGIN = 'https://aglyn-tenant-aglyn.vercel.app';
|
|
202
|
+
/** The middleware's custom-domain sentinel (`apps/tenant/utils/get-host.ts`). */ const CNAME_HOST_PREFIX = 'cname--';
|
|
203
|
+
/** Letters past this are never recased, so the bit arithmetic stays in 32 bits. */ const MAX_SPELLING_BITS = 30;
|
|
204
|
+
function spellingLetters(host) {
|
|
205
|
+
return [
|
|
206
|
+
...host.toLowerCase()
|
|
207
|
+
].map((char, index)=>/[a-z]/.test(char) ? index : -1).filter((index)=>index >= 0).slice(0, MAX_SPELLING_BITS);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Spelling number `n` of one site: a `?tenantHost=` value that resolves to
|
|
211
|
+
* that site and is a `[host]` route segment no other number produces, so the
|
|
212
|
+
* page it asks for has no cache entry to come from.
|
|
213
|
+
*
|
|
214
|
+
* - `{sub}.{apex}` → the full name, letters upper-cased by the bits of `n`,
|
|
215
|
+
* then one trailing dot per exhausted round of cases.
|
|
216
|
+
* - any other name → `cname--{name}`, recased the same way and cycling after
|
|
217
|
+
* `2 ** letters` (that form keeps trailing dots), with the prefix left
|
|
218
|
+
* lower-case because the canonical-domain redirect tests the raw segment.
|
|
219
|
+
*
|
|
220
|
+
* MUST stay identical to `freshHostSpelling` in
|
|
221
|
+
* `tools/scripts/lib/prod-canary.mjs`; `apps/tenant/specs/probe-host-spelling.spec.ts`
|
|
222
|
+
* pins both against `normalizeHostAlias`.
|
|
223
|
+
*/ export function freshHostSpelling(host, n, apex = TENANT_APEX) {
|
|
224
|
+
const subdomain = host.endsWith(`.${apex}`);
|
|
225
|
+
const prefix = subdomain ? '' : CNAME_HOST_PREFIX;
|
|
226
|
+
const chars = [
|
|
227
|
+
...host.toLowerCase()
|
|
228
|
+
];
|
|
229
|
+
const letters = spellingLetters(host);
|
|
230
|
+
const capacity = 2 ** letters.length;
|
|
231
|
+
const value = Math.max(0, Math.floor(Number(n) || 0));
|
|
232
|
+
const bits = value % capacity;
|
|
233
|
+
letters.forEach((index, bit)=>{
|
|
234
|
+
if (Math.floor(bits / 2 ** bit) % 2 === 1) chars[index] = chars[index].toUpperCase();
|
|
235
|
+
});
|
|
236
|
+
const dots = subdomain ? '.'.repeat(Math.floor(value / capacity)) : '';
|
|
237
|
+
return `${prefix}${chars.join('')}${dots}`;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The spelling number for a run at `nowMs`: minutes since 2026-10-01, so it
|
|
241
|
+
* never repeats on one deployment — a `{sub}.aglyn.app` name of 18 letters
|
|
242
|
+
* gains its first trailing dot after half a year.
|
|
243
|
+
*/ export function renderSpellingNumber(nowMs) {
|
|
244
|
+
return Math.max(1, Math.floor((nowMs - Date.UTC(2026, 9, 1)) / 60000));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* `"a.example.app b.example.app/services, https://b.example.app/contact"` →
|
|
248
|
+
* one entry per host, paths merged, `/` when an entry names none. Entries that
|
|
249
|
+
* are not a hostname are dropped, as `RENDER_MONITOR_ORIGINS` drops them.
|
|
250
|
+
*/ export function parseRenderMonitorPages(text) {
|
|
251
|
+
const byHost = new Map();
|
|
252
|
+
for (const raw of String(text != null ? text : '').split(/[\s,]+/)){
|
|
253
|
+
var _byHost_get;
|
|
254
|
+
const entry = raw.trim().replace(/^https?:\/\//i, '');
|
|
255
|
+
if (!entry) continue;
|
|
256
|
+
const slash = entry.indexOf('/');
|
|
257
|
+
const host = (slash === -1 ? entry : entry.slice(0, slash)).toLowerCase();
|
|
258
|
+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) continue;
|
|
259
|
+
const path = slash === -1 ? '/' : entry.slice(slash).replace(/\/+$/, '') || '/';
|
|
260
|
+
const paths = (_byHost_get = byHost.get(host)) != null ? _byHost_get : [];
|
|
261
|
+
if (!paths.includes(path)) paths.push(path);
|
|
262
|
+
byHost.set(host, paths);
|
|
263
|
+
}
|
|
264
|
+
return [
|
|
265
|
+
...byHost
|
|
266
|
+
].map(([host, paths])=>({
|
|
267
|
+
origin: `https://${host}`,
|
|
268
|
+
host,
|
|
269
|
+
paths
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Which real pages the monitor renders, and through which origin.
|
|
274
|
+
*
|
|
275
|
+
* `RENDER_MONITOR_PAGES` (same grammar as the canary's `CANARY_TENANT_HOSTS`)
|
|
276
|
+
* and `RENDER_MONITOR_RENDER_ORIGIN`, else — on Aglyn's own console only —
|
|
277
|
+
* `PLATFORM_RENDER_PAGES` through `PLATFORM_RENDER_ORIGIN`. `off` (here or in
|
|
278
|
+
* `RENDER_MONITOR_ORIGINS`) renders none. No render origin, no pages.
|
|
279
|
+
*/ export function resolveRenderMonitorPages(env) {
|
|
280
|
+
var _env_RENDER_MONITOR_PAGES, _env_RENDER_MONITOR_ORIGINS;
|
|
281
|
+
const off = /^(off|none|false|0)$/i;
|
|
282
|
+
const raw = String((_env_RENDER_MONITOR_PAGES = env['RENDER_MONITOR_PAGES']) != null ? _env_RENDER_MONITOR_PAGES : '').trim();
|
|
283
|
+
if (off.test(raw) || off.test(String((_env_RENDER_MONITOR_ORIGINS = env['RENDER_MONITOR_ORIGINS']) != null ? _env_RENDER_MONITOR_ORIGINS : '').trim())) {
|
|
284
|
+
return {
|
|
285
|
+
renderOrigin: '',
|
|
286
|
+
pages: []
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const platform = env['VERCEL_PROJECT_ID'] === PLATFORM_CONSOLE_VERCEL_PROJECT_ID;
|
|
290
|
+
const renderOrigin = originOf(env['RENDER_MONITOR_RENDER_ORIGIN']) || (platform ? PLATFORM_RENDER_ORIGIN : '');
|
|
291
|
+
const pages = parseRenderMonitorPages(raw || (platform ? PLATFORM_RENDER_PAGES : ''));
|
|
292
|
+
return renderOrigin ? {
|
|
293
|
+
renderOrigin,
|
|
294
|
+
pages
|
|
295
|
+
} : {
|
|
296
|
+
renderOrigin: '',
|
|
297
|
+
pages: []
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** A configured value as an origin, or '' when it is not one. */ export function originOf(value) {
|
|
301
|
+
const trimmed = String(value != null ? value : '').trim().replace(/\/+$/, '');
|
|
302
|
+
return /^https?:\/\/[^/\s?#]+$/i.test(trimmed) ? trimmed.toLowerCase() : '';
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Which sites the monitor renders, from the console's environment.
|
|
306
|
+
*
|
|
307
|
+
* 1. The install's demonstration site: `OPERATOR_HEALTH_TENANT_ORIGIN` when
|
|
308
|
+
* set (the one published site the operator alerts tick already watches),
|
|
309
|
+
* else `https://<AGLYN_TENANT_DEMO or demo>.<tenant apex>`.
|
|
310
|
+
* 2. Plus every origin in `RENDER_MONITOR_ORIGINS`, comma separated.
|
|
311
|
+
*
|
|
312
|
+
* `RENDER_MONITOR_ORIGINS=off` watches nothing, for an install with no
|
|
313
|
+
* published site to watch. Values that are not origins are dropped.
|
|
314
|
+
*/ export function resolveRenderMonitorTargets(env) {
|
|
315
|
+
var _env_RENDER_MONITOR_ORIGINS, _env_NEXT_PUBLIC_TENANT_DOMAIN, _env_AGLYN_TENANT_DEMO;
|
|
316
|
+
const raw = String((_env_RENDER_MONITOR_ORIGINS = env['RENDER_MONITOR_ORIGINS']) != null ? _env_RENDER_MONITOR_ORIGINS : '').trim();
|
|
317
|
+
if (/^(off|none|false|0)$/i.test(raw)) return [];
|
|
318
|
+
const apex = String((_env_NEXT_PUBLIC_TENANT_DOMAIN = env['NEXT_PUBLIC_TENANT_DOMAIN']) != null ? _env_NEXT_PUBLIC_TENANT_DOMAIN : '').trim() || TENANT_APEX;
|
|
319
|
+
const label = String((_env_AGLYN_TENANT_DEMO = env['AGLYN_TENANT_DEMO']) != null ? _env_AGLYN_TENANT_DEMO : '').trim() || 'demo';
|
|
320
|
+
const demo = originOf(env['OPERATOR_HEALTH_TENANT_ORIGIN']) || originOf(`https://${label}.${apex}`);
|
|
321
|
+
const listed = raw.split(/[\s,]+/).map((value)=>originOf(value)).filter(Boolean);
|
|
322
|
+
// A site whose pages are rendered is watched whole: its layout probes too.
|
|
323
|
+
const paged = resolveRenderMonitorPages(env).pages.map((page)=>page.origin);
|
|
324
|
+
return [
|
|
325
|
+
...new Set([
|
|
326
|
+
demo,
|
|
327
|
+
...listed,
|
|
328
|
+
...paged
|
|
329
|
+
].filter(Boolean))
|
|
330
|
+
];
|
|
331
|
+
}
|
|
332
|
+
/** The state document id for one origin. */ export function renderMonitorCheckId(origin) {
|
|
333
|
+
return `${RENDER_MONITOR_CHECK_PREFIX}${new URL(origin).host}`;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The next state for one site, given what this run saw. Pure.
|
|
337
|
+
*
|
|
338
|
+
* The status turns `degraded` only once `threshold` runs in a row have
|
|
339
|
+
* failed, so one slow cold start or one dropped connection says nothing. It
|
|
340
|
+
* turns back on the first passing run, because a render that works is
|
|
341
|
+
* proof, and a recovery held back is a recovery nobody hears about.
|
|
342
|
+
*/ export function nextRenderMonitorState(prior, observation, context) {
|
|
343
|
+
var _observation_detail, _ref, _ref1, _ref2, _ref3;
|
|
344
|
+
const { origin, label, threshold, now } = context;
|
|
345
|
+
const checkId = renderMonitorCheckId(origin);
|
|
346
|
+
const detail = String((_observation_detail = observation.detail) != null ? _observation_detail : '').slice(0, 1000);
|
|
347
|
+
const wasDegraded = (prior == null ? void 0 : prior.status) === 'degraded';
|
|
348
|
+
if (observation.ok) {
|
|
349
|
+
return {
|
|
350
|
+
next: {
|
|
351
|
+
checkId,
|
|
352
|
+
label,
|
|
353
|
+
origin,
|
|
354
|
+
status: 'ok',
|
|
355
|
+
sinceMs: !wasDegraded && (prior == null ? void 0 : prior.sinceMs) ? prior.sinceMs : now,
|
|
356
|
+
detail,
|
|
357
|
+
updatedAtMs: now,
|
|
358
|
+
consecutiveFailures: 0,
|
|
359
|
+
failingSinceMs: null,
|
|
360
|
+
lastOkAtMs: now
|
|
361
|
+
},
|
|
362
|
+
transition: wasDegraded ? 'recovered' : null
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const failures = ((_ref = prior == null ? void 0 : prior.consecutiveFailures) != null ? _ref : 0) + 1;
|
|
366
|
+
const failingSinceMs = (_ref1 = prior == null ? void 0 : prior.failingSinceMs) != null ? _ref1 : now;
|
|
367
|
+
const degraded = wasDegraded || failures >= Math.max(1, threshold);
|
|
368
|
+
return {
|
|
369
|
+
next: {
|
|
370
|
+
checkId,
|
|
371
|
+
label,
|
|
372
|
+
origin,
|
|
373
|
+
status: degraded ? 'degraded' : 'ok',
|
|
374
|
+
sinceMs: degraded ? wasDegraded && (prior == null ? void 0 : prior.sinceMs) ? prior.sinceMs : failingSinceMs : (_ref2 = prior == null ? void 0 : prior.sinceMs) != null ? _ref2 : now,
|
|
375
|
+
detail,
|
|
376
|
+
updatedAtMs: now,
|
|
377
|
+
consecutiveFailures: failures,
|
|
378
|
+
failingSinceMs,
|
|
379
|
+
lastOkAtMs: (_ref3 = prior == null ? void 0 : prior.lastOkAtMs) != null ? _ref3 : null
|
|
380
|
+
},
|
|
381
|
+
transition: degraded && !wasDegraded ? 'failing' : null
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The default store: one Firestore transaction on the site's document, so
|
|
386
|
+
* two overlapping runs cannot both decide they saw the edge.
|
|
387
|
+
*/ const firestoreStore = async (checkId, work)=>{
|
|
388
|
+
const { default: firebaseAdmin } = await import("./firebase-admin.js");
|
|
389
|
+
const db = firebaseAdmin.app().firestore();
|
|
390
|
+
const ref = db.collection(RENDER_MONITOR_COLLECTION).doc(checkId);
|
|
391
|
+
return db.runTransaction(async (transaction)=>{
|
|
392
|
+
const snapshot = await transaction.get(ref);
|
|
393
|
+
const prior = snapshot.exists ? snapshot.data() : null;
|
|
394
|
+
const { next, result } = work(prior);
|
|
395
|
+
transaction.set(ref, next);
|
|
396
|
+
return result;
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
const defaultRaise = async (type, options)=>{
|
|
400
|
+
const { raiseOperatorAlert } = await import("./operator-alerts.js");
|
|
401
|
+
return raiseOperatorAlert(type, options);
|
|
402
|
+
};
|
|
403
|
+
function describeDuration(ms) {
|
|
404
|
+
const minutes = Math.max(1, Math.round(ms / 60000));
|
|
405
|
+
if (minutes < 60) return `${minutes} min`;
|
|
406
|
+
const hours = Math.floor(minutes / 60);
|
|
407
|
+
const rest = minutes % 60;
|
|
408
|
+
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The `page` probe's URL for one run: this run's page of the site's list
|
|
412
|
+
* (rotating every five-minute slot), under this run's spelling of the site.
|
|
413
|
+
*/ export function pageProbeUrl(renderOrigin, site, slot, spelling) {
|
|
414
|
+
var _site_paths_;
|
|
415
|
+
const path = (_site_paths_ = site.paths[Math.abs(slot) % site.paths.length]) != null ? _site_paths_ : '/';
|
|
416
|
+
const url = new URL(path, renderOrigin);
|
|
417
|
+
url.searchParams.set('tenantHost', freshHostSpelling(site.host, spelling));
|
|
418
|
+
return url.toString();
|
|
419
|
+
}
|
|
420
|
+
function probeLine(row) {
|
|
421
|
+
return `${row.kind} ${new URL(row.url).pathname}: ${row.detail} (${row.ms} ms)`;
|
|
422
|
+
}
|
|
423
|
+
/** Fetch and grade one probe. Never throws. */ async function runProbe(url, spec, options) {
|
|
424
|
+
const startedAt = options.clock();
|
|
425
|
+
try {
|
|
426
|
+
var _response_headers_get;
|
|
427
|
+
const response = await options.fetcher(url, {
|
|
428
|
+
redirect: 'manual',
|
|
429
|
+
cache: 'no-store',
|
|
430
|
+
headers: _extends({
|
|
431
|
+
'user-agent': 'aglyn-render-monitor',
|
|
432
|
+
accept: 'text/html,application/xhtml+xml'
|
|
433
|
+
}, options.headers),
|
|
434
|
+
signal: AbortSignal.timeout(options.timeoutMs)
|
|
435
|
+
});
|
|
436
|
+
const body = await response.text();
|
|
437
|
+
const verdict = gradeRenderProbe({
|
|
438
|
+
status: response.status,
|
|
439
|
+
contentType: response.headers.get('content-type'),
|
|
440
|
+
body,
|
|
441
|
+
location: response.headers.get('location'),
|
|
442
|
+
cacheState: (_response_headers_get = response.headers.get('x-vercel-cache')) != null ? _response_headers_get : response.headers.get('x-nextjs-cache')
|
|
443
|
+
}, spec.expectStatus);
|
|
444
|
+
return _extends({
|
|
445
|
+
kind: spec.kind,
|
|
446
|
+
url,
|
|
447
|
+
ms: options.clock() - startedAt,
|
|
448
|
+
httpStatus: response.status
|
|
449
|
+
}, verdict);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
var _ref, _ref1;
|
|
452
|
+
var _error_cause;
|
|
453
|
+
const name = error == null ? void 0 : error.name;
|
|
454
|
+
const timedOut = name === 'TimeoutError' || name === 'AbortError';
|
|
455
|
+
return {
|
|
456
|
+
kind: spec.kind,
|
|
457
|
+
url,
|
|
458
|
+
ms: options.clock() - startedAt,
|
|
459
|
+
httpStatus: null,
|
|
460
|
+
ok: false,
|
|
461
|
+
challenged: false,
|
|
462
|
+
code: timedOut ? 'timeout' : 'unreachable',
|
|
463
|
+
detail: timedOut ? `no complete page in ${Math.round(options.timeoutMs / 1000)} s` : `unreachable (${(_ref = (_ref1 = error == null ? void 0 : (_error_cause = error.cause) == null ? void 0 : _error_cause.code) != null ? _ref1 : name) != null ? _ref : 'error'})`
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* One run: render every target, record each site's state, raise the edge.
|
|
469
|
+
*
|
|
470
|
+
* A dry run fetches and grades and writes nothing, so a person can ask
|
|
471
|
+
* "what would the monitor see right now" without moving its state.
|
|
472
|
+
*/ export async function runRenderMonitor(options = {}) {
|
|
473
|
+
var _options_env, _options_now, _options_store, _options_raise, _env_RENDER_MONITOR_FAILURE_THRESHOLD, _options_nonce, _options_fetcher, _options_headers, _options_timeoutMs;
|
|
474
|
+
const env = (_options_env = options.env) != null ? _options_env : process.env;
|
|
475
|
+
const clock = (_options_now = options.now) != null ? _options_now : Date.now;
|
|
476
|
+
const store = (_options_store = options.store) != null ? _options_store : firestoreStore;
|
|
477
|
+
const raise = (_options_raise = options.raise) != null ? _options_raise : defaultRaise;
|
|
478
|
+
const dryRun = Boolean(options.dryRun);
|
|
479
|
+
const parsedThreshold = Number.parseInt(String((_env_RENDER_MONITOR_FAILURE_THRESHOLD = env['RENDER_MONITOR_FAILURE_THRESHOLD']) != null ? _env_RENDER_MONITOR_FAILURE_THRESHOLD : ''), 10);
|
|
480
|
+
const threshold = Number.isFinite(parsedThreshold) && parsedThreshold >= 1 ? parsedThreshold : RENDER_MONITOR_DEFAULT_THRESHOLD;
|
|
481
|
+
const nonce = (_options_nonce = options.nonce) != null ? _options_nonce : ()=>`${clock().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
482
|
+
const probeOptions = {
|
|
483
|
+
fetcher: (_options_fetcher = options.fetcher) != null ? _options_fetcher : fetch,
|
|
484
|
+
headers: (_options_headers = options.headers) != null ? _options_headers : {},
|
|
485
|
+
timeoutMs: (_options_timeoutMs = options.timeoutMs) != null ? _options_timeoutMs : RENDER_MONITOR_TIMEOUT_MS,
|
|
486
|
+
clock
|
|
487
|
+
};
|
|
488
|
+
// Real pages need the render origin AND the bypass that gets past its
|
|
489
|
+
// Deployment Protection; without both a `page` probe could only fail.
|
|
490
|
+
const { renderOrigin, pages } = resolveRenderMonitorPages(env);
|
|
491
|
+
const canRenderPages = Boolean(renderOrigin && probeOptions.headers['x-vercel-protection-bypass']);
|
|
492
|
+
const runAt = clock();
|
|
493
|
+
const slot = Math.floor(runAt / 300000);
|
|
494
|
+
const sites = await Promise.all(resolveRenderMonitorTargets(env).map(async (origin)=>{
|
|
495
|
+
const layoutProbes = RENDER_PROBES.map((spec)=>runProbe(`${origin}${spec.path(nonce())}`, spec, probeOptions));
|
|
496
|
+
const site = canRenderPages ? pages.find((page)=>page.origin === origin) : undefined;
|
|
497
|
+
const pageProbe = site ? [
|
|
498
|
+
runProbe(pageProbeUrl(renderOrigin, site, slot, renderSpellingNumber(runAt)), {
|
|
499
|
+
kind: 'page',
|
|
500
|
+
expectStatus: 200
|
|
501
|
+
}, probeOptions)
|
|
502
|
+
] : [];
|
|
503
|
+
const probes = await Promise.all([
|
|
504
|
+
...layoutProbes,
|
|
505
|
+
...pageProbe
|
|
506
|
+
]);
|
|
507
|
+
const ok = probes.every((probe)=>probe.ok);
|
|
508
|
+
const result = {
|
|
509
|
+
origin,
|
|
510
|
+
ok,
|
|
511
|
+
probes
|
|
512
|
+
};
|
|
513
|
+
if (dryRun) return result;
|
|
514
|
+
const host = new URL(origin).host;
|
|
515
|
+
const failing = probes.filter((probe)=>!probe.ok);
|
|
516
|
+
const detail = ok ? 'Fresh renders pass.' : failing.map(probeLine).join('; ');
|
|
517
|
+
try {
|
|
518
|
+
const now = clock();
|
|
519
|
+
const { next, transition, failingSinceMs } = await store(renderMonitorCheckId(origin), (prior)=>{
|
|
520
|
+
var _ref, _ref1;
|
|
521
|
+
const decided = nextRenderMonitorState(prior, {
|
|
522
|
+
ok,
|
|
523
|
+
detail
|
|
524
|
+
}, {
|
|
525
|
+
origin,
|
|
526
|
+
label: `Page rendering on ${host}`,
|
|
527
|
+
threshold,
|
|
528
|
+
now
|
|
529
|
+
});
|
|
530
|
+
return {
|
|
531
|
+
next: decided.next,
|
|
532
|
+
result: _extends({}, decided, {
|
|
533
|
+
// When the failures a recovery ends began.
|
|
534
|
+
failingSinceMs: (_ref = (_ref1 = prior == null ? void 0 : prior.failingSinceMs) != null ? _ref1 : prior == null ? void 0 : prior.sinceMs) != null ? _ref : now
|
|
535
|
+
})
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
result.status = next.status;
|
|
539
|
+
result.consecutiveFailures = next.consecutiveFailures;
|
|
540
|
+
result.transition = transition;
|
|
541
|
+
if (transition === 'failing') {
|
|
542
|
+
await raise('system.siteRenderFailing', {
|
|
543
|
+
dedupeKey: host,
|
|
544
|
+
context: {
|
|
545
|
+
site: host,
|
|
546
|
+
failures: next.consecutiveFailures,
|
|
547
|
+
since: describeDuration(now - next.sinceMs),
|
|
548
|
+
detail
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
} else if (transition === 'recovered') {
|
|
552
|
+
await raise('system.siteRenderRecovered', {
|
|
553
|
+
dedupeKey: host,
|
|
554
|
+
context: {
|
|
555
|
+
site: host,
|
|
556
|
+
duration: describeDuration(now - failingSinceMs)
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
} catch (error) {
|
|
561
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
562
|
+
console.error(`[render-monitor] ${host} could not be recorded`, error);
|
|
563
|
+
}
|
|
564
|
+
return result;
|
|
565
|
+
}));
|
|
566
|
+
return {
|
|
567
|
+
dryRun,
|
|
568
|
+
threshold,
|
|
569
|
+
sites
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
//# sourceMappingURL=render-monitor.js.map
|