@teamscala/service-boot 0.23.389 → 0.23.394
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 +11 -1
- package/src/fn-routers/email-defaults.ts +15 -9
- package/src/fn-routers/email-link-ttl.ts +15 -10
- package/src/fn-routers/public-assessment-router.ts +101 -18
- package/src/handlers/bun-stable-watch.ts +10 -13
- package/src/handlers/singleton-tools-currency-watch.ts +3 -7
- package/src/read-html-template.ts +14 -4
- package/src/scala-tools-currency-watchdog.ts +20 -20
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.394",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
"@teamscala/generic-registry": "*",
|
|
35
35
|
"@teamscala/http": "*",
|
|
36
36
|
"@teamscala/logger": "*",
|
|
37
|
+
"@teamscala/notify-cache": "*",
|
|
37
38
|
"@teamscala/object-storage": "*",
|
|
38
39
|
"@teamscala/orpc": "*",
|
|
39
40
|
"@teamscala/os": "*",
|
|
41
|
+
"@teamscala/publish-registry": "*",
|
|
40
42
|
"@teamscala/realtime-utils": "*",
|
|
41
43
|
"@teamscala/resolver-engine": "*",
|
|
42
44
|
"@teamscala/timing": "*",
|
|
@@ -562,6 +564,14 @@
|
|
|
562
564
|
"types": "./src/load-service-prisma-client.d.ts",
|
|
563
565
|
"default": "./src/load-service-prisma-client.ts"
|
|
564
566
|
},
|
|
567
|
+
"./module-not-found": {
|
|
568
|
+
"types": "./src/module-not-found.d.ts",
|
|
569
|
+
"default": "./src/module-not-found.ts"
|
|
570
|
+
},
|
|
571
|
+
"./module-not-found.ts": {
|
|
572
|
+
"types": "./src/module-not-found.d.ts",
|
|
573
|
+
"default": "./src/module-not-found.ts"
|
|
574
|
+
},
|
|
565
575
|
"./mount": {
|
|
566
576
|
"types": "./src/mount.d.ts",
|
|
567
577
|
"default": "./src/mount.ts"
|
|
@@ -3,20 +3,26 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* from-address
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Hot snapshot of `email-defaults.default_sender` (outbound transactional
|
|
7
|
+
* from-address). Replaces the former process.env.DEFAULT_EMAIL_SENDER read.
|
|
8
|
+
* Invalidated by NOTIFY on `registry_config_changed`; `staleAfterMs` is the
|
|
9
|
+
* safety ceiling for an upstream regression that drops notifications.
|
|
10
10
|
*/
|
|
11
|
+
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
11
12
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
12
13
|
import * as v from "valibot";
|
|
13
14
|
|
|
14
15
|
const FALLBACK_SENDER = "noreply@gateway.scala.business";
|
|
15
|
-
|
|
16
|
+
const defaultSenderCache = createNotifyCache<string>({
|
|
17
|
+
name: "service-boot:default-email-sender",
|
|
18
|
+
load: async () => {
|
|
19
|
+
const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
|
|
20
|
+
return (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
|
|
21
|
+
},
|
|
22
|
+
invalidateOn: ["registry_config_changed"],
|
|
23
|
+
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
+
});
|
|
16
25
|
|
|
17
26
|
export async function getDefaultEmailSender(): Promise<string> {
|
|
18
|
-
|
|
19
|
-
const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
|
|
20
|
-
cached = (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
|
|
21
|
-
return cached;
|
|
27
|
+
return defaultSenderCache.get();
|
|
22
28
|
}
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the source of truth, not the literal).
|
|
6
|
+
* Hot snapshot of `email-links.email_link_ttl_ms` (magic-link validity
|
|
7
|
+
* window, dedup'd across the 3 fn-routers that mint them). Replaces the
|
|
8
|
+
* former per-file EMAIL_LINK_TTL_MS constant. Invalidated by NOTIFY on
|
|
9
|
+
* `registry_config_changed`.
|
|
11
10
|
*/
|
|
11
|
+
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
12
12
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
13
13
|
import * as v from "valibot";
|
|
14
14
|
|
|
15
15
|
const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
|
-
|
|
16
|
+
const emailLinkTtlCache = createNotifyCache<number>({
|
|
17
|
+
name: "service-boot:email-link-ttl",
|
|
18
|
+
load: async () => {
|
|
19
|
+
const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
|
|
20
|
+
return (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
|
|
21
|
+
},
|
|
22
|
+
invalidateOn: ["registry_config_changed"],
|
|
23
|
+
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
+
});
|
|
17
25
|
|
|
18
26
|
export async function getEmailLinkTtlMs(): Promise<number> {
|
|
19
|
-
|
|
20
|
-
const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
|
|
21
|
-
cached = (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
|
|
22
|
-
return cached;
|
|
27
|
+
return emailLinkTtlCache.get();
|
|
23
28
|
}
|
|
@@ -25,10 +25,11 @@ import * as v from "valibot";
|
|
|
25
25
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
26
26
|
import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
|
|
27
27
|
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
|
|
28
|
+
import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
|
|
28
29
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
29
30
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
31
|
+
import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
|
|
30
32
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
31
|
-
import { getDefaultEmailSender } from "./email-defaults.ts";
|
|
32
33
|
|
|
33
34
|
interface Context {
|
|
34
35
|
prisma: any;
|
|
@@ -106,9 +107,9 @@ function bandForScore(
|
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
109
|
* Best-effort branded Authority Index Report email. Self-contained: loads its
|
|
109
|
-
* own Resend secret, swallows every error
|
|
110
|
-
*
|
|
111
|
-
*
|
|
110
|
+
* own Resend secret, swallows every error. Sender identity + brand tokens
|
|
111
|
+
* come from resolveEmailSender (per-org email_sender row). On Resend refusal
|
|
112
|
+
* the call falls back to the platform sender and logs the reason.
|
|
112
113
|
*/
|
|
113
114
|
async function sendAuthorityReportEmail(opts: {
|
|
114
115
|
to: string;
|
|
@@ -117,6 +118,8 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
117
118
|
bandLabel: string;
|
|
118
119
|
categoryScores: Record<string, number>;
|
|
119
120
|
ctaUrl?: string;
|
|
121
|
+
organisationId?: string | null;
|
|
122
|
+
prisma: any;
|
|
120
123
|
}): Promise<void> {
|
|
121
124
|
const log = getAppLogger();
|
|
122
125
|
const rawSecret = await loadRegistryConfig(
|
|
@@ -132,17 +135,42 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
132
135
|
return;
|
|
133
136
|
}
|
|
134
137
|
initResendClient(decrypted.token);
|
|
135
|
-
|
|
138
|
+
|
|
139
|
+
const brandRow =
|
|
140
|
+
opts.organisationId && opts.prisma
|
|
141
|
+
? await fetchOrgBrand(
|
|
142
|
+
opts.prisma as Parameters<typeof fetchOrgBrand>[0],
|
|
143
|
+
opts.organisationId,
|
|
144
|
+
).catch(() => null)
|
|
145
|
+
: null;
|
|
146
|
+
const sender = await resolveEmailSender(opts.organisationId ?? null, {
|
|
147
|
+
brand: brandRow
|
|
148
|
+
? {
|
|
149
|
+
orgName: brandRow.companyName ?? "",
|
|
150
|
+
primaryColor: brandRow.primaryColor,
|
|
151
|
+
accentColor: brandRow.accentColor ?? null,
|
|
152
|
+
logoUrl: brandRow.logo.light ?? null,
|
|
153
|
+
}
|
|
154
|
+
: undefined,
|
|
155
|
+
});
|
|
156
|
+
const primary = sender.brand.primaryColor;
|
|
157
|
+
const muted = "#6b7280"; // structural neutral for body labels
|
|
158
|
+
const logoUrl = sender.brand.logoUrl;
|
|
159
|
+
const logoHtml =
|
|
160
|
+
logoUrl && /^https?:\/\//.test(logoUrl)
|
|
161
|
+
? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
|
|
162
|
+
: "";
|
|
163
|
+
|
|
136
164
|
// Subject + body from the `authority-index-report` email_template row, not
|
|
137
|
-
// literals here (email-template-registry-type).
|
|
138
|
-
//
|
|
139
|
-
//
|
|
165
|
+
// literals here (email-template-registry-type). Dynamic markup is rendered
|
|
166
|
+
// here and passed as merge vars — the row owns copy + layout; colours +
|
|
167
|
+
// logo come from the org brand tokens, NOT inline literals.
|
|
140
168
|
const pillarRows = (
|
|
141
169
|
Object.entries(opts.categoryScores) as Array<[string, number]>
|
|
142
170
|
)
|
|
143
171
|
.map(
|
|
144
172
|
([name, score]) =>
|
|
145
|
-
`<tr><td style="padding:6px 0;color
|
|
173
|
+
`<tr><td style="padding:6px 0;color:${muted}">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:${primary}">${score} / 100</td></tr>`,
|
|
146
174
|
)
|
|
147
175
|
.join("");
|
|
148
176
|
const pillarsText = (
|
|
@@ -156,27 +184,80 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
156
184
|
bandLabel: opts.bandLabel,
|
|
157
185
|
pillarRows,
|
|
158
186
|
pillarsText,
|
|
187
|
+
brandPrimaryColor: primary,
|
|
188
|
+
brandAccentColor: sender.brand.accentColor ?? primary,
|
|
189
|
+
brandLogoHtml: logoHtml,
|
|
190
|
+
orgName: sender.brand.orgName,
|
|
159
191
|
ctaHtml: opts.ctaUrl
|
|
160
|
-
? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background
|
|
192
|
+
? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:${primary};color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
|
|
161
193
|
: "",
|
|
162
194
|
ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
|
|
163
195
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
196
|
+
|
|
197
|
+
const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
|
|
198
|
+
sendEmail({
|
|
199
|
+
from: fromHeader,
|
|
200
|
+
to: opts.to,
|
|
201
|
+
subject: tpl.subject,
|
|
202
|
+
html: tpl.bodyHtml,
|
|
203
|
+
text: tpl.bodyText,
|
|
204
|
+
reply_to: replyToHeader,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let res = await sendOnce(sender.from, sender.replyTo);
|
|
208
|
+
let usedFrom = sender.from;
|
|
209
|
+
if (!res.success) {
|
|
210
|
+
// Resend refused the org sender (likely unverified domain). Fall back
|
|
211
|
+
// to the platform sender; never fail because a verification lookup
|
|
212
|
+
// failed — the resolver returns the row, the failure is at Resend.
|
|
213
|
+
const platformRow = await loadRegistryConfig(
|
|
214
|
+
"config",
|
|
215
|
+
"email-defaults",
|
|
216
|
+
v.looseObject({ default_sender: v.optional(v.string()) }),
|
|
217
|
+
).catch(() => null);
|
|
218
|
+
const fallbackFrom =
|
|
219
|
+
(platformRow?.default_sender as string | undefined)?.trim() ||
|
|
220
|
+
"scala <noreply@gateway.scala.business>";
|
|
221
|
+
log.warn(
|
|
222
|
+
`[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
|
|
223
|
+
);
|
|
224
|
+
res = await sendOnce(fallbackFrom, undefined);
|
|
225
|
+
usedFrom = fallbackFrom;
|
|
226
|
+
}
|
|
227
|
+
|
|
171
228
|
if (res.success) {
|
|
172
229
|
log.info(
|
|
173
|
-
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
|
|
230
|
+
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
|
|
174
231
|
);
|
|
175
232
|
} else {
|
|
176
233
|
log.error(
|
|
177
234
|
`[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
|
|
178
235
|
);
|
|
179
236
|
}
|
|
237
|
+
|
|
238
|
+
// email-send-audited: best-effort audit row. Guarded so a stale prisma
|
|
239
|
+
// client (scala-quiz schema predates email_send_log) skips cleanly.
|
|
240
|
+
try {
|
|
241
|
+
const auditPrisma = opts.prisma as {
|
|
242
|
+
email_send_log?: { create: (args: unknown) => Promise<unknown> };
|
|
243
|
+
};
|
|
244
|
+
if (auditPrisma?.email_send_log) {
|
|
245
|
+
await auditPrisma.email_send_log.create({
|
|
246
|
+
data: {
|
|
247
|
+
to_address: opts.to,
|
|
248
|
+
template_slug: "authority-index-report",
|
|
249
|
+
status: res.success ? "sent" : "failed",
|
|
250
|
+
provider_message_id: res.id ?? null,
|
|
251
|
+
error: res.success ? null : (res.error ?? "unknown"),
|
|
252
|
+
from_address: usedFrom,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} catch (auditErr) {
|
|
257
|
+
log.warn(
|
|
258
|
+
`[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
180
261
|
}
|
|
181
262
|
|
|
182
263
|
function scoreAnswers(
|
|
@@ -382,6 +463,8 @@ export const publicAssessmentRouter = {
|
|
|
382
463
|
bandLabel: band.label,
|
|
383
464
|
categoryScores,
|
|
384
465
|
ctaUrl,
|
|
466
|
+
organisationId: orgId,
|
|
467
|
+
prisma,
|
|
385
468
|
}).catch((e) =>
|
|
386
469
|
log.error(
|
|
387
470
|
`[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
25
25
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
26
26
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
|
+
import { getDistTagVersion } from "@teamscala/publish-registry";
|
|
27
28
|
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
30
31
|
const TARGET_STABLE = "1.4.0"; // flip back when a stable at or above this ships
|
|
31
32
|
|
|
32
33
|
interface BunLatestManifest {
|
|
@@ -54,23 +55,19 @@ function isStableAtOrAbove(version: string, target: string): boolean {
|
|
|
54
55
|
|
|
55
56
|
async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
56
57
|
const log = getAppLogger();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!res.ok) throw new Error(`npm bun/latest HTTP ${res.status}`);
|
|
61
|
-
manifest = (await res.json()) as BunLatestManifest;
|
|
62
|
-
} catch (err) {
|
|
63
|
-
log.warn(`[bun-stable-watch] failed to fetch npm bun/latest: ${err}`);
|
|
58
|
+
const version = await getDistTagVersion("bun", "latest", NPM_REGISTRY);
|
|
59
|
+
if (!version) {
|
|
60
|
+
log.warn("[bun-stable-watch] failed to fetch npm bun/latest");
|
|
64
61
|
return;
|
|
65
62
|
}
|
|
66
|
-
if (isStableAtOrAbove(
|
|
63
|
+
if (isStableAtOrAbove(version, TARGET_STABLE)) {
|
|
67
64
|
log.warn(
|
|
68
|
-
`[bun-stable-watch] Bun stable ${
|
|
65
|
+
`[bun-stable-watch] Bun stable ${version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
|
|
69
66
|
);
|
|
70
67
|
recordEvent({
|
|
71
68
|
kind: "bun.stable.shipped",
|
|
72
69
|
payload: {
|
|
73
|
-
latest:
|
|
70
|
+
latest: version,
|
|
74
71
|
targetStable: TARGET_STABLE,
|
|
75
72
|
note: "escape hatch bun-14-lock-resolution-pin ready to retire; verify railpack 1.4.0 support then run the atomic flip-back",
|
|
76
73
|
},
|
|
@@ -78,11 +75,11 @@ async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<voi
|
|
|
78
75
|
return;
|
|
79
76
|
}
|
|
80
77
|
log.info(
|
|
81
|
-
`[bun-stable-watch] npm bun/latest = ${
|
|
78
|
+
`[bun-stable-watch] npm bun/latest = ${version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
|
|
82
79
|
);
|
|
83
80
|
recordEvent({
|
|
84
81
|
kind: "bun.watch.tick",
|
|
85
|
-
payload: { latest:
|
|
82
|
+
payload: { latest: version, targetStable: TARGET_STABLE },
|
|
86
83
|
});
|
|
87
84
|
}
|
|
88
85
|
|
|
@@ -28,6 +28,7 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
|
28
28
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
29
29
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
30
30
|
import { spawn } from "@teamscala/os/spawn";
|
|
31
|
+
import { getDistTagVersion } from "@teamscala/publish-registry";
|
|
31
32
|
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -180,7 +181,7 @@ async function fetchLatestGithubRelease(repo: string): Promise<string | null> {
|
|
|
180
181
|
async function fetchLatestVersion(pkg: string, distTag: string): Promise<string | null> {
|
|
181
182
|
try {
|
|
182
183
|
// @teamscala/* packages use the internal dist-tag tool; public npm packages
|
|
183
|
-
// fetch directly from the npm registry
|
|
184
|
+
// fetch directly from the npm registry via the typed primitive.
|
|
184
185
|
if (pkg.startsWith("@teamscala/")) {
|
|
185
186
|
const result = await spawn({
|
|
186
187
|
name: "singleton-tools-currency:dist-tag",
|
|
@@ -196,12 +197,7 @@ async function fetchLatestVersion(pkg: string, distTag: string): Promise<string
|
|
|
196
197
|
if (!latest || !parseSemver(latest)) return null;
|
|
197
198
|
return latest;
|
|
198
199
|
}
|
|
199
|
-
|
|
200
|
-
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/${encodeURIComponent(distTag)}`;
|
|
201
|
-
const res = await fetch(url, { headers: { accept: "application/json" } });
|
|
202
|
-
if (!res.ok) return null;
|
|
203
|
-
const manifest = (await res.json()) as { version: string };
|
|
204
|
-
return manifest.version ?? null;
|
|
200
|
+
return getDistTagVersion(pkg, distTag, "https://registry.npmjs.org");
|
|
205
201
|
} catch {
|
|
206
202
|
return null;
|
|
207
203
|
}
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* bytes across restarts).
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
42
43
|
import { readClientBuildStatus } from "./build-client-assets.ts";
|
|
43
44
|
import { join } from "node:path";
|
|
44
45
|
|
|
@@ -70,7 +71,15 @@ const PAGE_DOCUMENT = `<!doctype html>
|
|
|
70
71
|
</body>
|
|
71
72
|
</html>`;
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
interface CachedHtml {
|
|
75
|
+
html: string;
|
|
76
|
+
manifestMtime: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const htmlCache = createCache<CachedHtml>("service-boot:html-template", {
|
|
80
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
81
|
+
maxSize: 1,
|
|
82
|
+
});
|
|
74
83
|
|
|
75
84
|
function manifestPath(): string {
|
|
76
85
|
return join(process.cwd(), "dist", "client", "asset-manifest.json");
|
|
@@ -98,8 +107,9 @@ async function loadManifest(): Promise<AssetManifest> {
|
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
export async function readHtmlTemplate(): Promise<string> {
|
|
101
|
-
const
|
|
102
|
-
|
|
110
|
+
const manifestMtime = await manifestMtimeMs();
|
|
111
|
+
const cached = htmlCache.get("default");
|
|
112
|
+
if (cached && cached.manifestMtime === manifestMtime) return cached.html;
|
|
103
113
|
const manifest = await loadManifest();
|
|
104
114
|
let html = PAGE_DOCUMENT
|
|
105
115
|
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
@@ -116,6 +126,6 @@ export async function readHtmlTemplate(): Promise<string> {
|
|
|
116
126
|
? html.replace("</head>", `${meta}</head>`)
|
|
117
127
|
: `${meta}${html}`;
|
|
118
128
|
}
|
|
119
|
-
|
|
129
|
+
htmlCache.set("default", { html, manifestMtime });
|
|
120
130
|
return html;
|
|
121
131
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Polls the installed scala-tools package against GitHub Packages and repairs
|
|
7
7
|
* stale daemon tooling by reinstalling and letting PM2 restart the process.
|
|
8
|
+
* The fetch routes through `getGithubPackagesLatestVersion` so the URL
|
|
9
|
+
* encoding and GitHub-Packages auth header live in the typed primitive.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
@@ -14,6 +16,7 @@ import { createWatchdog } from "@teamscala/watchdog/create-watchdog";
|
|
|
14
16
|
import type { WatchdogHandle } from "@teamscala/watchdog/types";
|
|
15
17
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
16
18
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
19
|
+
import { getGithubPackagesLatestVersion } from "@teamscala/publish-registry";
|
|
17
20
|
import * as v from "valibot";
|
|
18
21
|
|
|
19
22
|
const PACKAGE_NAME = "@teamscala/scala-tools";
|
|
@@ -55,10 +58,6 @@ interface PackageManifest {
|
|
|
55
58
|
version?: unknown;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
interface PackagePackument {
|
|
59
|
-
"dist-tags"?: { latest?: unknown };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
61
|
let watchdog: WatchdogHandle | null = null;
|
|
63
62
|
|
|
64
63
|
async function readInstalledVersion(cwd: string): Promise<string | null> {
|
|
@@ -70,24 +69,25 @@ async function readInstalledVersion(cwd: string): Promise<string | null> {
|
|
|
70
69
|
}
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* PACKAGE_URL is `<registry>/<encoded-name>`. Split it so the typed primitive
|
|
74
|
+
* owns the URL encoding (encodeURIComponent) and the GitHub-Packages auth
|
|
75
|
+
* header shape — neither is this file's concern.
|
|
76
|
+
*/
|
|
77
|
+
function registryFromPackageUrl(pkgUrl: string): { registry: string; name: string } {
|
|
78
|
+
const lastSlash = pkgUrl.lastIndexOf("/");
|
|
79
|
+
if (lastSlash < 0) return { registry: pkgUrl, name: "" };
|
|
80
|
+
return {
|
|
81
|
+
registry: pkgUrl.slice(0, lastSlash),
|
|
82
|
+
name: decodeURIComponent(pkgUrl.slice(lastSlash + 1)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readLatestVersion(_fetchTimeoutMs: number): Promise<string | null> {
|
|
74
87
|
const token = await getGithubPackagesToken();
|
|
75
88
|
if (!token) return null;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
headers: {
|
|
79
|
-
accept: "application/vnd.github+json",
|
|
80
|
-
authorization: `Bearer ${token}`,
|
|
81
|
-
},
|
|
82
|
-
signal: AbortSignal.timeout(fetchTimeoutMs),
|
|
83
|
-
});
|
|
84
|
-
if (!response.ok) return null;
|
|
85
|
-
const packument = (await response.json()) as PackagePackument;
|
|
86
|
-
const latest = packument["dist-tags"]?.latest;
|
|
87
|
-
return typeof latest === "string" ? latest : null;
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
89
|
+
const { registry, name } = registryFromPackageUrl(PACKAGE_URL);
|
|
90
|
+
return getGithubPackagesLatestVersion(name, token, registry);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
async function checkCurrency(): Promise<{ healthy: boolean; details?: Record<string, unknown> }> {
|