@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Calls a createPageHandler() handler with the per-request extras the
|
|
7
|
+
* SSR pipeline needs: the resolved userId/orgId (from localhost-bypass
|
|
8
|
+
* or better-auth-cookie), and the htmlTemplate. Shared by the regions
|
|
9
|
+
* and page_slug shell variants.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
|
+
import type { Context } from "hono";
|
|
14
|
+
import { readHtmlTemplate } from "./read-html-template.ts";
|
|
15
|
+
import {
|
|
16
|
+
PrimaryResourceMissingError,
|
|
17
|
+
buildResourceNotFoundResponse,
|
|
18
|
+
resolveErrorPageLogo,
|
|
19
|
+
} from "./resource-not-found.ts";
|
|
20
|
+
import { extractOrgIdFromCallbackPath } from "./resolvers/portal-route-paths.ts";
|
|
21
|
+
|
|
22
|
+
type PageHandler = (
|
|
23
|
+
req: Request,
|
|
24
|
+
extras: Record<string, unknown>,
|
|
25
|
+
) => Promise<{ body: unknown; status: number; headers: HeadersInit }>;
|
|
26
|
+
|
|
27
|
+
export async function bindPageHandler(
|
|
28
|
+
c: Context,
|
|
29
|
+
handler: PageHandler,
|
|
30
|
+
authMode: string,
|
|
31
|
+
serviceSlug: string,
|
|
32
|
+
label: string,
|
|
33
|
+
resourceOrgId?: string,
|
|
34
|
+
): Promise<Response> {
|
|
35
|
+
const log = getAppLogger();
|
|
36
|
+
try {
|
|
37
|
+
let userId: string | undefined;
|
|
38
|
+
let orgId: string | undefined;
|
|
39
|
+
let visitor: { id: string; name: string; email: string } | null = null;
|
|
40
|
+
|
|
41
|
+
// NO localhost super-admin bypass: identity comes ONLY from the session
|
|
42
|
+
// (the auth-gate already forces sign-in for authed services on every
|
|
43
|
+
// host, 127.0.0.1 included). The old `if (isLocalhost) userId="super-admin"`
|
|
44
|
+
// rendered authed pages as a privileged user on localhost regardless of
|
|
45
|
+
// who (if anyone) was signed in — so agent/tool verification on 127.0.0.1
|
|
46
|
+
// saw a view that diverged from the real signed-in user's. Tools that need
|
|
47
|
+
// an authed render sign in and carry the cookie (getSuperAdminSessionCookie).
|
|
48
|
+
|
|
49
|
+
// The .scala.business session cookie reaches EVERY app on the domain,
|
|
50
|
+
// so a signed-in platform user is identifiable at SSR regardless of
|
|
51
|
+
// this service's own auth mode. Resolving it per-request (never via
|
|
52
|
+
// the cross-request loader graph) lets identity-gated blocks
|
|
53
|
+
// (PublicGptBlock's SignInBlock gate) first-paint the real experience
|
|
54
|
+
// instead of a client-side "checking who you are" loading pass
|
|
55
|
+
// (ssr-then-live-handoff). authMode still governs AUTHORIZATION;
|
|
56
|
+
// this is read-only identity convenience.
|
|
57
|
+
const hasSessionCookie = /better-auth\.session_token=/.test(
|
|
58
|
+
c.req.header("cookie") ?? "",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (authMode === "better-auth-cookie" || hasSessionCookie) {
|
|
62
|
+
// Visitor identity is an ENHANCEMENT: on auth:none services
|
|
63
|
+
// (scala-public et al.) no auth factory is registered, and a
|
|
64
|
+
// signed-in operator's domain-wide cookie reaches every
|
|
65
|
+
// *.scala.business site — getSession throwing "Auth factory not
|
|
66
|
+
// registered" 500'd every page for signed-in visitors
|
|
67
|
+
// (public-dev outage, 2026-06-11). Degrade to anonymous, never
|
|
68
|
+
// fail the render (block-and-provider-error-isolation).
|
|
69
|
+
try {
|
|
70
|
+
const { getSession } = await import("@teamscala/auth/get-session.ts");
|
|
71
|
+
const session = await getSession(c.req.raw.headers);
|
|
72
|
+
if (authMode === "better-auth-cookie") {
|
|
73
|
+
userId = session?.user?.id;
|
|
74
|
+
orgId = session?.session.activeOrganizationId ?? undefined;
|
|
75
|
+
}
|
|
76
|
+
if (session?.user?.email) {
|
|
77
|
+
visitor = {
|
|
78
|
+
id: session.user.id,
|
|
79
|
+
name: session.user.name ?? session.user.email,
|
|
80
|
+
email: session.user.email,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (authMode === "better-auth-cookie") throw err;
|
|
85
|
+
// cookie-only enhancement path: anonymous render proceeds
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Anonymous dynamic routes (proposal/form/quiz/agent) resolve a
|
|
90
|
+
// resource whose owning org is known from the loader. That org owns
|
|
91
|
+
// the brand the block-level serverLoaders read — override the
|
|
92
|
+
// viewer's org (if any) so the SSR injects the resource owner's
|
|
93
|
+
// logo/colours, not the viewer's.
|
|
94
|
+
if (resourceOrgId) orgId = resourceOrgId;
|
|
95
|
+
|
|
96
|
+
// Unauthed /sign-in (no session, no resource) would otherwise resolve no
|
|
97
|
+
// org, and the brand resolver falls back to the super-admin org (GBG Indo)
|
|
98
|
+
// — so a link shared to org A (e.g. GMG APAC) previews as org B's brand on
|
|
99
|
+
// WhatsApp/crawlers, because the link 302→/sign-in for the unauthed crawler.
|
|
100
|
+
// The callbackURL carries the org the visitor is signing in to; derive orgId
|
|
101
|
+
// from it so the sign-in page's brand/title/og reflect the destination org.
|
|
102
|
+
// The canonical portal shape is /<websiteId>/<orgId>/<path> — the org is
|
|
103
|
+
// segment 1, NOT segment 0. Reading segment 0 (the website id) fed a website
|
|
104
|
+
// id into brand resolution, which resolved no org and fell back to the
|
|
105
|
+
// super-admin org — the exact cross-tenant mis-branding this block exists to
|
|
106
|
+
// fix, still live until 2026-07-21; violates portal-branding-resolves-page-org-first.
|
|
107
|
+
// scala-projects routes /<orgId>/… (single UUID) and proposal/form/quiz hosts
|
|
108
|
+
// route /<resourceId>/… (a non-org first segment) — both keep segment 0 via
|
|
109
|
+
// the helper's single-UUID fallback (resolves to no brand, falls back as
|
|
110
|
+
// before). The helper is the single authority shared with the route resolver.
|
|
111
|
+
if (!orgId) {
|
|
112
|
+
const cb = c.req.query("callbackURL") ?? "";
|
|
113
|
+
const resolved = extractOrgIdFromCallbackPath(cb);
|
|
114
|
+
if (resolved) orgId = resolved;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const nonce = c.get("nonce") as string | undefined;
|
|
118
|
+
|
|
119
|
+
const resp = await handler(c.req.raw, {
|
|
120
|
+
userId,
|
|
121
|
+
orgId,
|
|
122
|
+
visitor,
|
|
123
|
+
searchParams: c.req.query(),
|
|
124
|
+
htmlTemplate: await readHtmlTemplate(),
|
|
125
|
+
nonce,
|
|
126
|
+
});
|
|
127
|
+
// Per-page root.props.maxWidth (the canonical mechanism) centers any
|
|
128
|
+
// page at the chosen width via inline styles — already hydration-safe
|
|
129
|
+
// and page-scoped. Bind-page-handler MUST NOT inject unscoped CSS
|
|
130
|
+
// selectors: a global `.puck-root-slot { max-width }` rule shrinks
|
|
131
|
+
// every portal pane that hosts a Puck root (the 2026-06-17 dashboard
|
|
132
|
+
// sliver bug — `.puck-root-slot` matches every Puck page in every
|
|
133
|
+
// region, including the dashboard center pane). The `no-bind-page-handler-css-injection`
|
|
134
|
+
// guard enforces this; this comment is the load-bearing rationale.
|
|
135
|
+
// Live dashboards must never be served from a stale cache (intermediary,
|
|
136
|
+
// browser back-forward, mobile Safari aggressive caching). Force every
|
|
137
|
+
// SSR HTML response to be revalidated. Static assets (CSS/JS bundles)
|
|
138
|
+
// keep their hash-busted caching — those are content-addressed.
|
|
139
|
+
// no-transform: intermediaries must not mutate SSR HTML — Cloudflare's
|
|
140
|
+
// Email Address Obfuscation rewrites mailto: hrefs at the edge, which
|
|
141
|
+
// breaks the server-serializes-client-reads-verbatim hydration contract
|
|
142
|
+
// (React recoverable-error on every page with a mailto link). Per CF
|
|
143
|
+
// docs, Email Obfuscation does not apply when no-transform is present.
|
|
144
|
+
// NOTE: a shared-edge-caching (CDN max-age) variant shipped briefly on 2026-06-10
|
|
145
|
+
// and was REMOVED by operator ruling the same day ("the origin should
|
|
146
|
+
// be so fast we don't care") — the origin-speed work (SWR caches,
|
|
147
|
+
// wave-parallel loader, brotli) is the sanctioned path; re-adding
|
|
148
|
+
// shared-cache headers needs a fresh operator ruling.
|
|
149
|
+
const headers = new Headers(resp.headers as HeadersInit);
|
|
150
|
+
headers.set(
|
|
151
|
+
"Cache-Control",
|
|
152
|
+
"no-store, no-cache, no-transform, must-revalidate, max-age=0",
|
|
153
|
+
);
|
|
154
|
+
headers.set("Pragma", "no-cache");
|
|
155
|
+
headers.set("Expires", "0");
|
|
156
|
+
// Viewport-aware slot loading varies the response by these headers.
|
|
157
|
+
// Even with Cache-Control: no-store, browsers and intermediaries
|
|
158
|
+
// honour Vary on back/forward navigation and BFCache; Accept-CH
|
|
159
|
+
// asks the browser to send Sec-CH-UA-Mobile on subsequent
|
|
160
|
+
// navigations (Chromium-family auto-sends, others opt in).
|
|
161
|
+
headers.set("Vary", "Sec-CH-UA-Mobile, User-Agent");
|
|
162
|
+
headers.set("Accept-CH", "Sec-CH-UA-Mobile");
|
|
163
|
+
// Origin-side gzip. Cache-Control: no-transform (load-bearing above —
|
|
164
|
+
// it disables Cloudflare HTML rewrites that corrupt hydration) ALSO
|
|
165
|
+
// makes hono's compress() middleware skip this response, which shipped
|
|
166
|
+
// 1.2MB of raw SSR HTML per page load through the dev tunnel (~3s of
|
|
167
|
+
// pure transfer; the operator's tablet timed out — 2026-06-06).
|
|
168
|
+
// no-transform forbids INTERMEDIARY transforms; origin-applied
|
|
169
|
+
// content-coding is part of the representation (RFC 9110 §8.4.1), so
|
|
170
|
+
// compressing here honors both. Bun.gzipSync on ~1.2MB costs ~10ms.
|
|
171
|
+
const acceptEnc = c.req.header("Accept-Encoding") ?? "";
|
|
172
|
+
// Brotli preferred when offered (every modern browser): ~15-20%
|
|
173
|
+
// smaller than gzip on SSR HTML. Quality 4, NOT the library default
|
|
174
|
+
// 11 — q11 costs hundreds of ms on a ~1MB page (defeats the point);
|
|
175
|
+
// q4 compresses in single-digit ms and still beats gzip.
|
|
176
|
+
if (/\bbr\b/.test(acceptEnc) && resp.body) {
|
|
177
|
+
const { brotliCompressSync, constants } = await import("node:zlib");
|
|
178
|
+
const raw = new Uint8Array(await new Response(resp.body as never).arrayBuffer());
|
|
179
|
+
const br = brotliCompressSync(raw, {
|
|
180
|
+
params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
|
|
181
|
+
});
|
|
182
|
+
headers.set("Content-Encoding", "br");
|
|
183
|
+
headers.delete("Content-Length");
|
|
184
|
+
return new Response(br as never, { status: resp.status, headers });
|
|
185
|
+
}
|
|
186
|
+
if (/\bgzip\b/.test(acceptEnc) && resp.body) {
|
|
187
|
+
const raw = new Uint8Array(await new Response(resp.body as never).arrayBuffer());
|
|
188
|
+
const gz = Bun.gzipSync(raw);
|
|
189
|
+
headers.set("Content-Encoding", "gzip");
|
|
190
|
+
headers.delete("Content-Length");
|
|
191
|
+
return new Response(gz as never, { status: resp.status, headers });
|
|
192
|
+
}
|
|
193
|
+
return new Response(resp.body as never, { status: resp.status, headers });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Dead entity link (wrong id, deleted row) → the branded not-found
|
|
196
|
+
// page with a real 404 — never a 500, never default-content leakage.
|
|
197
|
+
if (err instanceof PrimaryResourceMissingError) {
|
|
198
|
+
log.warn(
|
|
199
|
+
`[scala-install:${serviceSlug}] 404 ${err.resourceType} not found (${label}) for ${new URL(c.req.url).pathname}`,
|
|
200
|
+
);
|
|
201
|
+
return buildResourceNotFoundResponse(
|
|
202
|
+
err.resourceType,
|
|
203
|
+
await resolveErrorPageLogo(serviceSlug),
|
|
204
|
+
err.httpStatus,
|
|
205
|
+
// "Take me home" → the host root (relative "/" resolves to the
|
|
206
|
+
// current site's home; the portal redirects / to its dashboard).
|
|
207
|
+
// empty → the page-row-exists-but-zero-blocks "This page is empty"
|
|
208
|
+
// surface, distinct from a dead link (operator ruling 2026-07-21).
|
|
209
|
+
{ homeUrl: "/", empty: err.empty },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
log.error(
|
|
213
|
+
`[scala-install:${serviceSlug}] Render error (${label}) for ${new URL(c.req.url).pathname}:`,
|
|
214
|
+
err,
|
|
215
|
+
);
|
|
216
|
+
return new Response(
|
|
217
|
+
`Render error: ${err instanceof Error ? err.message : String(err)}`,
|
|
218
|
+
{
|
|
219
|
+
status: 500,
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Input hashing for the client-asset build fingerprint.
|
|
7
|
+
*
|
|
8
|
+
* Split out of build-client-assets.ts, which had grown to 1221 lines holding
|
|
9
|
+
* six distinct concerns (lock + status, manifest, hydrate bundle, source maps,
|
|
10
|
+
* stylesheet/tailwind, and this). `single-purpose-file` did not catch it: that
|
|
11
|
+
* rule flags a file whose top-level exports share no common name root, which
|
|
12
|
+
* measures export-NAME cohesion rather than how many concerns a file holds — so
|
|
13
|
+
* a large file with few tidily-named exports stays invisible to it.
|
|
14
|
+
*
|
|
15
|
+
* This module owns ONE concern: turning the build's real inputs into a stable
|
|
16
|
+
* hash. `computeInputFingerprint` (still in the parent, and still the only
|
|
17
|
+
* exported entry point) composes the two functions exported here; everything
|
|
18
|
+
* else is module-private because nothing outside needs it.
|
|
19
|
+
*
|
|
20
|
+
* Byte-for-byte move — imports adjusted, nothing else. No renames, no type
|
|
21
|
+
* changes (`migrations-copy-not-rewrite`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { existsSync } from "node:fs";
|
|
26
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
27
|
+
|
|
28
|
+
// Resolve + hash the content of every `@import` in app.css's @teamscala/*
|
|
29
|
+
// stylesheet chain — INCLUDING transitive @imports (generic-ui/styles.css
|
|
30
|
+
// @imports blk-utilities/styles.css; hashing only the direct import left
|
|
31
|
+
// blk-utilities changes undetected → stale bundle). Cycle-safe via `seen`.
|
|
32
|
+
export async function hashResolvedPackageStylesheets(
|
|
33
|
+
cwd: string,
|
|
34
|
+
h: ReturnType<typeof createHash>,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
let appCss: string;
|
|
37
|
+
try {
|
|
38
|
+
appCss = await Bun.file(join(cwd, "src", "app.css")).text();
|
|
39
|
+
} catch {
|
|
40
|
+
return; // no app.css → nothing to fold (the main loop already recorded <absent>)
|
|
41
|
+
}
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
const hashSpec = async (spec: string, base: string): Promise<void> => {
|
|
44
|
+
if (seen.has(spec)) return;
|
|
45
|
+
seen.add(spec);
|
|
46
|
+
try {
|
|
47
|
+
const resolved = await Bun.resolve(spec, base);
|
|
48
|
+
const content = await Bun.file(resolved).text();
|
|
49
|
+
h.update("pkgcss:").update(spec).update("\0").update(content).update("\0");
|
|
50
|
+
// Follow transitive @imports within this stylesheet (relative + @teamscala/*).
|
|
51
|
+
for (const child of [
|
|
52
|
+
...content.matchAll(/@import\s+["']([^"']+)["']/g),
|
|
53
|
+
].map((m) => m[1].trim())) {
|
|
54
|
+
if (child.startsWith("tailwindcss")) continue;
|
|
55
|
+
await hashSpec(child, dirname(resolved));
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
h.update("pkgcss:").update(spec).update("\0<unresolved>\0");
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
for (const spec of [
|
|
62
|
+
...appCss.matchAll(/@import\s+["'](@teamgbg\/[^"']+)["']/g),
|
|
63
|
+
].map((m) => m[1].trim())) {
|
|
64
|
+
await hashSpec(spec, join(cwd, "src"));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The JS twin of hashResolvedPackageStylesheets: resolve + hash the CONTENT of
|
|
69
|
+
// every @teamscala/* JS/TS module reachable from the client entrypoints, so a
|
|
70
|
+
// mid-install stale bake (bun.lock ahead of node_modules — see computeInputFingerprint)
|
|
71
|
+
// is detected on the next boot and self-heals. Third-party modules are NOT walked
|
|
72
|
+
// (their versions are pinned by bun.lock and they don't change on a @teamgbg
|
|
73
|
+
// publish); only the @teamgbg surface — the publish-driven change graph — is folded.
|
|
74
|
+
//
|
|
75
|
+
// Determinism: per-file content hashes are collected into a map keyed on the
|
|
76
|
+
// package-relative path, then folded into the aggregate hasher in SORTED path
|
|
77
|
+
// order. The walk itself runs bounded-concurrent (no-serial-per-unit-work-on-hot-
|
|
78
|
+
// paths — the @teamgbg graph is ~1k+ files for a large UI; a serial await-per-file
|
|
79
|
+
// walk starves the boot path), but scheduling order never affects the result.
|
|
80
|
+
const JS_IMPORT_SPEC_RE = /\b(?:from|import|export|require)\s*\(?["']([^"']+)["']/g;
|
|
81
|
+
const JS_GRAPH_CONCURRENCY = 32;
|
|
82
|
+
|
|
83
|
+
// Parse the package name (scoped `@scope/pkg` or unscoped `pkg`) from a
|
|
84
|
+
// resolved node_modules path. lastIndexOf picks the INNERMOST package when
|
|
85
|
+
// node_modules is nested (a -> node_modules/b), which is the package actually
|
|
86
|
+
// imported. Returns null for a path not under node_modules.
|
|
87
|
+
function nodeModulesPkgName(resolvedPath: string): string | null {
|
|
88
|
+
const idx = resolvedPath.lastIndexOf("/node_modules/");
|
|
89
|
+
if (idx < 0) return null;
|
|
90
|
+
const rest = resolvedPath.slice(idx + "/node_modules/".length);
|
|
91
|
+
const slash = rest.indexOf("/");
|
|
92
|
+
if (rest.startsWith("@")) {
|
|
93
|
+
if (slash < 0) return rest; // malformed scoped name with no slash
|
|
94
|
+
const secondSlash = rest.indexOf("/", slash + 1);
|
|
95
|
+
return secondSlash < 0 ? rest : rest.slice(0, secondSlash);
|
|
96
|
+
}
|
|
97
|
+
return slash < 0 ? rest : rest.slice(0, slash);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Best-effort read of an installed package's declared version. Used at the
|
|
101
|
+
// third-party closure boundary so a version bump of a closure dep (react, etc.)
|
|
102
|
+
// invalidates the fingerprint without content-walking the (large, pinned)
|
|
103
|
+
// package. Returns null on any failure — an ABSENT signal, not a masking
|
|
104
|
+
// fallback (protective-layers-fail-explicitly): the caller omits the package,
|
|
105
|
+
// so a later successful read folds in a real version and invalidates. Never
|
|
106
|
+
// returns a non-null placeholder that would hide the failure.
|
|
107
|
+
async function readInstalledPkgVersion(
|
|
108
|
+
resolvedPath: string,
|
|
109
|
+
pkgName: string,
|
|
110
|
+
): Promise<string | null> {
|
|
111
|
+
const marker = "/node_modules/";
|
|
112
|
+
const idx = resolvedPath.lastIndexOf(marker);
|
|
113
|
+
const pkgRoot = join(resolvedPath.slice(0, idx + marker.length), pkgName);
|
|
114
|
+
try {
|
|
115
|
+
const pkg = JSON.parse(await Bun.file(join(pkgRoot, "package.json")).text());
|
|
116
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function hashTeamgbgJsGraph(
|
|
123
|
+
cwd: string,
|
|
124
|
+
h: ReturnType<typeof createHash>,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const roots = [
|
|
127
|
+
join(cwd, "src", "entry-client.ts"),
|
|
128
|
+
join(cwd, "src", "page-manifest.ts"),
|
|
129
|
+
].filter((p) => existsSync(p));
|
|
130
|
+
if (roots.length === 0) return; // backend service — no client bundle to guard
|
|
131
|
+
|
|
132
|
+
const seen = new Set<string>();
|
|
133
|
+
for (const r of roots) seen.add(r);
|
|
134
|
+
const fileHashes = new Map<string, string>(); // pkg-relative path → content hash
|
|
135
|
+
const thirdPartyVersions = new Map<string, string>(); // pkg name → installed version
|
|
136
|
+
|
|
137
|
+
let frontier: string[] = [...roots];
|
|
138
|
+
while (frontier.length > 0) {
|
|
139
|
+
const batch = frontier.splice(0, JS_GRAPH_CONCURRENCY);
|
|
140
|
+
const expanded = await Promise.all(
|
|
141
|
+
batch.map(async (file) => {
|
|
142
|
+
let text: string;
|
|
143
|
+
try {
|
|
144
|
+
text = await Bun.file(file).text();
|
|
145
|
+
} catch {
|
|
146
|
+
return [] as string[];
|
|
147
|
+
}
|
|
148
|
+
const nmIdx = file.indexOf("/node_modules/@teamscala/");
|
|
149
|
+
if (nmIdx >= 0) {
|
|
150
|
+
const rel = file.slice(nmIdx + "/node_modules/".length); // @teamscala/pkg/...
|
|
151
|
+
fileHashes.set(rel, createHash("sha256").update(text).digest("hex"));
|
|
152
|
+
}
|
|
153
|
+
const next: string[] = [];
|
|
154
|
+
for (const m of text.matchAll(JS_IMPORT_SPEC_RE)) {
|
|
155
|
+
let resolved: string;
|
|
156
|
+
try {
|
|
157
|
+
resolved = await Bun.resolve(m[1].trim(), dirname(file));
|
|
158
|
+
} catch {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const inNodeModules = resolved.includes("/node_modules/");
|
|
162
|
+
const isTeamgbg = resolved.includes("/node_modules/@teamscala/");
|
|
163
|
+
if (inNodeModules && !isTeamgbg) {
|
|
164
|
+
// Third-party closure boundary: stop the content walk (large,
|
|
165
|
+
// version-pinned packages) but record this dep's resolved VERSION
|
|
166
|
+
// so a closure version bump (react, …) still invalidates the
|
|
167
|
+
// fingerprint. The closure-scoped replacement for hashing all of
|
|
168
|
+
// bun.lock (`no-uncontrolled-repetition-or-cascade`). has()+set()
|
|
169
|
+
// straddle an await, so two concurrent imports of the same package
|
|
170
|
+
// can both read its version — idempotent (same value), so the
|
|
171
|
+
// folded result is deterministic regardless of scheduling.
|
|
172
|
+
const pkgName = nodeModulesPkgName(resolved);
|
|
173
|
+
if (pkgName && !thirdPartyVersions.has(pkgName)) {
|
|
174
|
+
const version = await readInstalledPkgVersion(resolved, pkgName);
|
|
175
|
+
// null = absent (unreadable package.json) — OMIT so a later
|
|
176
|
+
// successful read invalidates; never mask behind a placeholder
|
|
177
|
+
// (protective-layers-fail-explicitly). has() stays false → the
|
|
178
|
+
// read is re-attempted on the next import (cheap, bounded by
|
|
179
|
+
// closure size); a successful read sets + dedups.
|
|
180
|
+
if (version !== null) thirdPartyVersions.set(pkgName, version);
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Atomic dedup: check+add are synchronous (no await between), so on
|
|
185
|
+
// the single-threaded event loop only one task ever enqueues a path.
|
|
186
|
+
if (seen.has(resolved)) continue;
|
|
187
|
+
seen.add(resolved);
|
|
188
|
+
next.push(resolved);
|
|
189
|
+
}
|
|
190
|
+
return next;
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
for (const arr of expanded) frontier.push(...arr);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const rel of [...fileHashes.keys()].sort()) {
|
|
197
|
+
h.update("tgjs:").update(rel).update("\0").update(fileHashes.get(rel) as string).update("\0");
|
|
198
|
+
}
|
|
199
|
+
// Fold the resolved versions of every third-party package at the closure
|
|
200
|
+
// boundary, sorted by name for determinism. This is the ONLY signal for
|
|
201
|
+
// third-party changes (their bodies are not content-walked); without it a
|
|
202
|
+
// react version bump in the closure would bake a stale bundle.
|
|
203
|
+
for (const name of [...thirdPartyVersions.keys()].sort()) {
|
|
204
|
+
h.update("tpv:").update(name).update("\0").update(thirdPartyVersions.get(name) as string).update("\0");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Tailwind source-candidate scanning for the client CSS build.
|
|
7
|
+
*
|
|
8
|
+
* Split out of build-client-assets.ts (1221 lines, six concerns). Owns ONE
|
|
9
|
+
* concern: turning the resolved `@source` directories into the candidate token
|
|
10
|
+
* set Tailwind's `build()` consumes.
|
|
11
|
+
*
|
|
12
|
+
* Why it exists at all, preserved from the original: Tailwind v4's low-level
|
|
13
|
+
* `compile()` does NOT scan content, and @tailwindcss/oxide's native Scanner is
|
|
14
|
+
* not a dependency of the bare `tailwindcss` lib — only of the integration
|
|
15
|
+
* packages. So the tokens are extracted here. Over-extraction is safe, because
|
|
16
|
+
* `build()` ignores any token that is not a valid utility. Dropping this scan is
|
|
17
|
+
* what shipped an unstyled page to the operator's tablet on 2026-06-13; it is
|
|
18
|
+
* enforced by `css-build-scans-source-candidates`.
|
|
19
|
+
*
|
|
20
|
+
* Byte-for-byte move — imports adjusted, nothing else.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
27
|
+
|
|
28
|
+
// Tailwind v4's low-level `compile()` does NOT scan content — `compiler.build(
|
|
29
|
+
// candidates)` only emits utilities for the candidate class names it is GIVEN.
|
|
30
|
+
// `build([])` therefore emits ZERO content-driven utilities (only the static
|
|
31
|
+
// theme/base + @utility/@layer + imported package CSS). The workspace's own
|
|
32
|
+
// blocks mostly use inline styles + hand-written `blk-*` package classes
|
|
33
|
+
// (no-tailwind-in-blocks / layout-properties-inline), so the missing utility
|
|
34
|
+
// layer was invisible — until the Plate document editor (third-party shadcn
|
|
35
|
+
// components using raw `flex`/`p-1`/`bg-background/95`) rendered completely
|
|
36
|
+
// unstyled (operator's tablet, 2026-06-13). The CLI we replaced
|
|
37
|
+
// (`b29e369` "in-process Bun compile") scanned content; the replacement dropped
|
|
38
|
+
// the scan. This restores it: glob every resolved `@source` dir, extract
|
|
39
|
+
// candidate tokens, and feed them to `build()`. @tailwindcss/oxide's native
|
|
40
|
+
// Scanner is NOT a dependency of the bare `tailwindcss` lib (only of the
|
|
41
|
+
// integration packages), so we tokenize ourselves — over-extraction is safe
|
|
42
|
+
// (build() ignores any token that isn't a valid utility). Enforced by
|
|
43
|
+
// `css-build-scans-source-candidates`.
|
|
44
|
+
const SCAN_EXTS = new Set([
|
|
45
|
+
".ts",
|
|
46
|
+
".tsx",
|
|
47
|
+
".js",
|
|
48
|
+
".jsx",
|
|
49
|
+
".mjs",
|
|
50
|
+
".cjs",
|
|
51
|
+
".html",
|
|
52
|
+
".md",
|
|
53
|
+
".mdx",
|
|
54
|
+
]);
|
|
55
|
+
// Class-name candidate chars: identifiers, variant `:`, opacity `/`, arbitrary
|
|
56
|
+
// values `[]()`, decimals `.`, important `!`, plus `#%,&-` seen in arbitrary
|
|
57
|
+
// values and modifiers. Deliberately broad — invalid tokens are dropped by build().
|
|
58
|
+
const CANDIDATE_RE = /[A-Za-z0-9_:/[\].%#!,&-]+/g;
|
|
59
|
+
|
|
60
|
+
export async function scanSourceCandidates(
|
|
61
|
+
sources: Array<{ base: string; pattern: string; negated?: boolean }>,
|
|
62
|
+
log: ReturnType<typeof getAppLogger>,
|
|
63
|
+
): Promise<string[]> {
|
|
64
|
+
const candidates = new Set<string>();
|
|
65
|
+
for (const src of sources) {
|
|
66
|
+
if (src.negated) continue;
|
|
67
|
+
let root: string;
|
|
68
|
+
try {
|
|
69
|
+
root = resolve(src.base, src.pattern);
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// A resolved @source root absent on disk is benign: a published block
|
|
74
|
+
// package ships compiled output, not its src/ tree, so an `@source` into
|
|
75
|
+
// that package resolves to a non-existent dir. Skip silently rather than
|
|
76
|
+
// warn on every UI-service boot (the log-flood class — the ENOENT whose
|
|
77
|
+
// path rendered with a trailing null byte). Real errors below still warn.
|
|
78
|
+
if (!existsSync(root)) continue;
|
|
79
|
+
// A root INSIDE node_modules is an explicit `@source` into an installed
|
|
80
|
+
// block package (the app-css transform emits one per package) — scan it
|
|
81
|
+
// fully. A root OUTSIDE node_modules is the service's own tree — scan it
|
|
82
|
+
// but never descend into node_modules (the entire dependency graph).
|
|
83
|
+
const rootInNodeModules = root.includes("/node_modules/");
|
|
84
|
+
try {
|
|
85
|
+
const glob = new Bun.Glob("**/*");
|
|
86
|
+
for await (const file of glob.scan({
|
|
87
|
+
cwd: root,
|
|
88
|
+
absolute: true,
|
|
89
|
+
onlyFiles: true,
|
|
90
|
+
})) {
|
|
91
|
+
if (!rootInNodeModules && file.includes("/node_modules/")) continue;
|
|
92
|
+
if (file.includes("/dist/") || file.endsWith(".d.ts")) continue;
|
|
93
|
+
const dot = file.lastIndexOf(".");
|
|
94
|
+
if (dot < 0 || !SCAN_EXTS.has(file.slice(dot))) continue;
|
|
95
|
+
let text: string;
|
|
96
|
+
try {
|
|
97
|
+
text = await readFile(file, "utf-8");
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const matched = text.match(CANDIDATE_RE);
|
|
102
|
+
if (matched) for (const t of matched) candidates.add(t);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// A bad source dir never aborts the whole CSS build (the other
|
|
106
|
+
// sources still contribute their utilities).
|
|
107
|
+
log.warn(
|
|
108
|
+
`[build-client-assets] candidate scan skipped ${root}: ${
|
|
109
|
+
err instanceof Error ? err.message : String(err)
|
|
110
|
+
}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...candidates];
|
|
115
|
+
}
|