@k2b/cloud 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/scripts/build.ts +3 -1
- package/scripts/sync-recovery-smoke.ts +1 -4
- package/src/_internal/define-app.ts +4 -0
- package/src/_internal/font-preloads.ts +25 -0
- package/src/_internal/nats-connection.ts +3 -10
- package/src/_internal/process-sync.ts +100 -21
- package/src/_internal/status-preserving-ssr.ts +9 -1
- package/src/_internal/sync-budget.ts +306 -0
- package/src/access/PermissionEditor.tsx +31 -12
- package/src/access/PrincipalPicker.tsx +23 -19
- package/src/access/messages.ts +2 -0
- package/src/access/service-account-kind.ts +28 -0
- package/src/account/EntitySearch.tsx +9 -8
- package/src/ai/chat-quotas.ts +10 -6
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/inference-calls.ts +2 -2
- package/src/ai/model-access.ts +5 -2
- package/src/ai/projects.ts +7 -2
- package/src/ai/quota-report.ts +6 -1
- package/src/ai/quotas.ts +1 -1
- package/src/ai/skills.ts +7 -2
- package/src/cli/access.ts +1 -0
- package/src/config/env.ts +5 -0
- package/src/server/locale.ts +17 -4
- package/src/server/middleware/validator.ts +23 -1
- package/src/services/gateway.ts +5 -0
- package/src/services/index.ts +2 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/pdf/markdown.ts +50 -12
- package/src/services/settings/core-settings.ts +2 -0
- package/src/shared/ai-quotas.ts +16 -3
- package/src/ssr/LayoutPreferences.island.tsx +2 -17
- package/src/ssr/MinimalLayout.tsx +44 -20
- package/src/ssr/MinimalLayoutPreferences.island.tsx +52 -0
- package/src/ssr/PageError.tsx +1 -1
- package/src/ssr/profile-preferences-messages.ts +4 -0
- package/src/styles/resource-search.css +2 -2
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-markdown-editor.css +2 -2
- package/src/styles/utilities-navigation.css +94 -20
- package/src/types/k2b-ui-fonts.d.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -93,23 +93,23 @@
|
|
|
93
93
|
"@firecrawl/anydoc": "0.2.4",
|
|
94
94
|
"@napi-rs/canvas": "1.0.9",
|
|
95
95
|
"pdfjs-dist": "6.3.289",
|
|
96
|
-
"@
|
|
97
|
-
"@
|
|
98
|
-
"@tabler/icons": "3.47.0",
|
|
96
|
+
"@simplewebauthn/server": "14.0.3",
|
|
97
|
+
"@tabler/icons": "3.48.0",
|
|
99
98
|
"@tailwindcss/typography": "0.5.20",
|
|
100
99
|
"@k2b/nessi": "0.12.1",
|
|
101
100
|
"@k2b/ssr": "0.14.0",
|
|
102
|
-
"@k2b/ui": "0.
|
|
103
|
-
"@k2b/stdlib": "0.
|
|
104
|
-
"@k2b/sync": "
|
|
101
|
+
"@k2b/ui": "0.10.0",
|
|
102
|
+
"@k2b/stdlib": "0.27.0",
|
|
103
|
+
"@k2b/sync": "7.0.0",
|
|
104
|
+
"@nats-io/jetstream": "3.4.0",
|
|
105
105
|
"@nats-io/transport-node": "3.4.0",
|
|
106
|
-
"@modelcontextprotocol/sdk": "1.30.
|
|
106
|
+
"@modelcontextprotocol/sdk": "1.30.1",
|
|
107
107
|
"bun-plugin-tailwind": "0.1.2",
|
|
108
|
-
"hono-openapi": "1.3.
|
|
108
|
+
"hono-openapi": "1.3.3",
|
|
109
109
|
"jose": "6.2.12",
|
|
110
|
-
"katex": "0.18.
|
|
110
|
+
"katex": "0.18.9",
|
|
111
111
|
"liquidjs": "10.29.0",
|
|
112
|
-
"marked": "
|
|
112
|
+
"marked": "18.0.14",
|
|
113
113
|
"mermaid": "12.0.0",
|
|
114
114
|
"nodemailer": "10.0.10",
|
|
115
115
|
"postcss": "8.5.28",
|
|
@@ -129,10 +129,10 @@
|
|
|
129
129
|
"@babel/preset-typescript": "8.0.1",
|
|
130
130
|
"@types/bun": "1.4.2",
|
|
131
131
|
"babel-preset-solid": "1.9.15",
|
|
132
|
-
"hono": "4.13.
|
|
133
|
-
"playwright": "1.
|
|
132
|
+
"hono": "4.13.9",
|
|
133
|
+
"playwright": "1.63.0",
|
|
134
134
|
"solid-js": "1.9.15",
|
|
135
|
-
"typescript": "
|
|
135
|
+
"typescript": "7.0.2",
|
|
136
136
|
"zod": "4.6.5"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
package/scripts/build.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { promisify } from "node:util";
|
|
|
32
32
|
import { brotliCompress, gzip, constants as zlibConstants } from "node:zlib";
|
|
33
33
|
import { Glob } from "bun";
|
|
34
34
|
import tailwind from "bun-plugin-tailwind";
|
|
35
|
+
import type { AppCliModules } from "../src/contracts/app";
|
|
35
36
|
import { writeAppFavicon } from "./app-favicon";
|
|
36
37
|
import { buildBrowserPerformance } from "./browser-performance";
|
|
37
38
|
import { buildPdfRenderer } from "./build-pdf-renderer";
|
|
@@ -181,7 +182,8 @@ if (existsSync(appAssets)) {
|
|
|
181
182
|
// `cld` plugins: one self-contained module bundle plus references per module.
|
|
182
183
|
// Imported after the app config so the framework sees the APP_DIR set above.
|
|
183
184
|
const { buildCliPlugin, writeCliPlugin } = await import("../src/_internal/cli-plugins");
|
|
184
|
-
|
|
185
|
+
const cliModules: AppCliModules = app?.meta.cli ?? {};
|
|
186
|
+
for (const [name, declaration] of Object.entries(cliModules)) {
|
|
185
187
|
const plugin = await buildCliPlugin({ appDir, appId, name, declaration, version });
|
|
186
188
|
await writeCliPlugin(resolve(dist, "cli", name), plugin);
|
|
187
189
|
}
|
|
@@ -68,10 +68,7 @@ Run "prepare", restart the application fleet while NATS stays up, then run "reco
|
|
|
68
68
|
check(phase === "prepare" || phase === "recover", "Pass prepare or recover");
|
|
69
69
|
const namespace = options.namespace?.trim() ?? "";
|
|
70
70
|
check(/^cloud-recovery-smoke-[A-Za-z0-9_-]{6,60}$/.test(namespace), "Pass a unique --namespace cloud-recovery-smoke-<suffix>");
|
|
71
|
-
const servers =
|
|
72
|
-
.split(",")
|
|
73
|
-
.map((value) => value.trim())
|
|
74
|
-
.filter(Boolean);
|
|
71
|
+
const servers = env.NATS_SERVERS.length ? env.NATS_SERVERS : ["nats://127.0.0.1:4222"];
|
|
75
72
|
const connection = await connect({ servers, name: `${APPLICATION}-${phase}`, ignoreClusterUpdates: true });
|
|
76
73
|
const sync = createSync({ connection, namespace, application: APPLICATION, defaults: { replicas: env.SYNC_REPLICAS } });
|
|
77
74
|
const job = sync.job<{ preparedAt: string }>({
|
|
@@ -48,6 +48,7 @@ import { requireInvocation } from "../server/middleware/invocation";
|
|
|
48
48
|
import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-template";
|
|
49
49
|
import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
|
|
50
50
|
import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
|
|
51
|
+
import { validationErrorResponse } from "../server/middleware/validator";
|
|
51
52
|
import {
|
|
52
53
|
capabilityInvocationOperation,
|
|
53
54
|
searchInvocationOperation,
|
|
@@ -72,6 +73,7 @@ import { readBoundedJson } from "./bounded-json";
|
|
|
72
73
|
import { appRuntimeMetadata } from "./build-metadata";
|
|
73
74
|
import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
|
|
74
75
|
import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
|
|
76
|
+
import { FONT_PRELOAD_LINKS } from "./font-preloads";
|
|
75
77
|
import { createHeartbeat } from "./heartbeat";
|
|
76
78
|
import { compileHelp } from "./help";
|
|
77
79
|
import { createPageResponses } from "./page-responses";
|
|
@@ -355,6 +357,7 @@ export const defineApp = <
|
|
|
355
357
|
<link rel="icon" href="${appFaviconHref(opts.id, v)}">
|
|
356
358
|
<style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
|
|
357
359
|
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
360
|
+
${FONT_PRELOAD_LINKS}
|
|
358
361
|
<link rel="stylesheet" href="/public/fonts.css?v=${v}">
|
|
359
362
|
<link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
|
|
360
363
|
<link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
|
|
@@ -774,6 +777,7 @@ export const defineApp = <
|
|
|
774
777
|
if (advertiseOpenapi) {
|
|
775
778
|
const apiPrefix = opts.openapi!.replace(/\/openapi\.json$/, "") || "/";
|
|
776
779
|
const spec = await generateSpecs(startOpts.openapi!, {
|
|
780
|
+
defaultValidationErrorResponse: validationErrorResponse,
|
|
777
781
|
documentation: {
|
|
778
782
|
info: {
|
|
779
783
|
title: meta.name,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference path="../types/k2b-ui-fonts.d.ts" />
|
|
2
|
+
import plexPreset from "@k2b/ui/fonts/plex.css" with { type: "text" };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preload tags for the IBM Plex Sans faces that every page sets in its first
|
|
6
|
+
* frame: Latin text at 400, 500 (buttons, labels) and 600 (headings).
|
|
7
|
+
*
|
|
8
|
+
* The preset declares its faces with `font-display: swap`. Without a preload
|
|
9
|
+
* the browser requests a face only when layout needs it, paints the fallback
|
|
10
|
+
* font and re-wraps the text once Plex arrives. That moves every box after the
|
|
11
|
+
* first changed line, and on a vertically centered page such as sign-in the
|
|
12
|
+
* whole form. Preloaded, the faces download next to the render-blocking
|
|
13
|
+
* stylesheets and the first frame already uses them.
|
|
14
|
+
*
|
|
15
|
+
* Core serves the faces under `/public/fonts/` with the preset's
|
|
16
|
+
* content-hashed file names (`packages/core/scripts/font-assets.ts`). The
|
|
17
|
+
* preset is bundled as text, so production servers need no `node_modules`.
|
|
18
|
+
*/
|
|
19
|
+
export const FONT_PRELOAD_LINKS = ["400", "500", "600"]
|
|
20
|
+
.map((weight) => {
|
|
21
|
+
const file = new RegExp(`url\\(\\./fonts/(ibm-plex-sans-latin-${weight}-normal-[0-9a-f]+\\.woff2)\\)`).exec(plexPreset)?.[1];
|
|
22
|
+
if (!file) throw new Error(`The @k2b/ui Plex preset has no Latin IBM Plex Sans ${weight} face`);
|
|
23
|
+
return `<link rel="preload" href="/public/fonts/${file}" as="font" type="font/woff2" crossorigin>`;
|
|
24
|
+
})
|
|
25
|
+
.join("\n ");
|
|
@@ -12,15 +12,9 @@ export type ConnectNatsOptions = {
|
|
|
12
12
|
name: string;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`NATS_SERVERS is not set (connection "${name}"). @k2b/sync needs a comma-separated list of ` +
|
|
19
|
-
"nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return connect({
|
|
15
|
+
/** Dial `NATS_SERVERS` once. `startProcessSync()` requires the list and retries while NATS does not answer. */
|
|
16
|
+
export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> =>
|
|
17
|
+
connect({
|
|
24
18
|
servers: env.NATS_SERVERS,
|
|
25
19
|
name,
|
|
26
20
|
ignoreClusterUpdates: env.NATS_IGNORE_CLUSTER_UPDATES,
|
|
@@ -31,4 +25,3 @@ export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsCon
|
|
|
31
25
|
...(env.NATS_CREDS_FILE ? { authenticator: credsAuthenticator(readFileSync(env.NATS_CREDS_FILE)) } : {}),
|
|
32
26
|
...(env.NATS_TLS_CA_FILE ? { tls: { caFile: env.NATS_TLS_CA_FILE } } : {}),
|
|
33
27
|
});
|
|
34
|
-
};
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
* nothing touches sync during module evaluation.
|
|
9
9
|
*/
|
|
10
10
|
import { hostname } from "node:os";
|
|
11
|
-
import { createSync, type Sync } from "@k2b/sync";
|
|
11
|
+
import { createSync, type Sync, SyncError } from "@k2b/sync";
|
|
12
|
+
import { expBackoff } from "@k2b/sync/retry";
|
|
13
|
+
import type { NatsConnection } from "@nats-io/transport-node";
|
|
12
14
|
import { env } from "../config/env";
|
|
15
|
+
import { logger } from "../services/logging";
|
|
13
16
|
import { flushSyncTraceEvents, observeSyncEvent } from "../services/logging/trace";
|
|
14
17
|
import { connectNats } from "./nats-connection";
|
|
18
|
+
import { withSyncBudgets } from "./sync-budget";
|
|
15
19
|
|
|
16
20
|
let current: Sync | undefined;
|
|
17
21
|
let starting = false;
|
|
@@ -64,7 +68,94 @@ export type ProcessSync = {
|
|
|
64
68
|
stop: () => Promise<void>;
|
|
65
69
|
};
|
|
66
70
|
|
|
67
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* How long a starting process waits for NATS and JetStream. After a host or
|
|
73
|
+
* stack restart, NATS nodes come up in any order and JetStream must recover
|
|
74
|
+
* its streams and elect a leader before its API answers; five minutes covers
|
|
75
|
+
* that. A process that still gets no answer exits instead of staying alive
|
|
76
|
+
* without readiness, so Docker or Kubernetes restarts it and the outage shows
|
|
77
|
+
* up as restarts.
|
|
78
|
+
*/
|
|
79
|
+
const NATS_STARTUP_BUDGET_MS = 5 * 60_000;
|
|
80
|
+
// About 1 s, doubling to 20 s, each ±50 % so processes that restarted together spread their retries.
|
|
81
|
+
const NATS_STARTUP_BACKOFF = { baseMs: 1_000, maxMs: 20_000, jitter: 0.5 };
|
|
82
|
+
|
|
83
|
+
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run `attempt` until it succeeds, with exponential backoff and jitter. A
|
|
87
|
+
* `SyncError` describes the server or the declarations (unsupported version,
|
|
88
|
+
* JetStream disabled, drift), which waiting does not change, so it fails at
|
|
89
|
+
* once. Every other failure (refused or unresolved address, timeout, JetStream
|
|
90
|
+
* without a leader) is logged and retried until `budgetMs` is spent; then the
|
|
91
|
+
* process exits with status 1.
|
|
92
|
+
*/
|
|
93
|
+
export const waitForNats = async <T>(
|
|
94
|
+
attempt: () => Promise<T>,
|
|
95
|
+
{ application, budgetMs = NATS_STARTUP_BUDGET_MS }: { application: string; budgetMs?: number },
|
|
96
|
+
): Promise<T> => {
|
|
97
|
+
const log = logger("sync");
|
|
98
|
+
const deadline = Date.now() + budgetMs;
|
|
99
|
+
for (let attempts = 1; ; attempts++) {
|
|
100
|
+
try {
|
|
101
|
+
const result = await attempt();
|
|
102
|
+
if (attempts > 1) log.info("NATS and JetStream answered", { application, attempts });
|
|
103
|
+
return result;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof SyncError) throw error;
|
|
106
|
+
const remainingMs = deadline - Date.now();
|
|
107
|
+
if (remainingMs <= 0) {
|
|
108
|
+
log.error("NATS and JetStream did not answer within the startup budget; exiting so the process is restarted", {
|
|
109
|
+
application,
|
|
110
|
+
attempts,
|
|
111
|
+
budgetMs,
|
|
112
|
+
error: errorMessage(error),
|
|
113
|
+
});
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
const retryInMs = Math.min(remainingMs, expBackoff(attempts, NATS_STARTUP_BACKOFF));
|
|
117
|
+
log.warn("NATS or JetStream is not ready; retrying", { application, attempt: attempts, retryInMs, error: errorMessage(error) });
|
|
118
|
+
await Bun.sleep(retryInMs);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** One start attempt: connect, bind a new Sync instance, and wait for `ready()`; release both when it fails. */
|
|
124
|
+
const connectReadySync = async (application: string): Promise<{ sync: Sync; connection: NatsConnection }> => {
|
|
125
|
+
const connection = await connectNats({ name: `${application}@${hostname()}` });
|
|
126
|
+
let sync: Sync | undefined;
|
|
127
|
+
try {
|
|
128
|
+
sync = withSyncBudgets(
|
|
129
|
+
createSync({
|
|
130
|
+
connection,
|
|
131
|
+
namespace: env.SYNC_NAMESPACE,
|
|
132
|
+
application,
|
|
133
|
+
defaults: { replicas: env.SYNC_REPLICAS },
|
|
134
|
+
observe: (event) => observeSyncEvent(event, application),
|
|
135
|
+
}),
|
|
136
|
+
{ connection, namespace: env.SYNC_NAMESPACE },
|
|
137
|
+
);
|
|
138
|
+
bindProcessSync(sync);
|
|
139
|
+
await sync.ready();
|
|
140
|
+
return { sync, connection };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (current === sync) unbindProcessSync();
|
|
143
|
+
await flushSyncTraceEvents();
|
|
144
|
+
await connection.close();
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Connect NATS, create and bind the process Sync instance, and wait until it is
|
|
151
|
+
* ready. Existing streams of its jobs, queues, and topics take the byte
|
|
152
|
+
* limits they declare before first use (see `sync-budget.ts`).
|
|
153
|
+
*
|
|
154
|
+
* While NATS or JetStream does not answer, it retries with a fresh connection
|
|
155
|
+
* and instance (see `waitForNats`), and exits the process once
|
|
156
|
+
* `NATS_STARTUP_BUDGET_MS` is spent. Callers are process entry points; nothing
|
|
157
|
+
* serves readiness before this returns.
|
|
158
|
+
*/
|
|
68
159
|
export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
|
|
69
160
|
if (!env.SYNC_NAMESPACE.trim()) {
|
|
70
161
|
throw new Error(
|
|
@@ -72,28 +163,16 @@ export const startProcessSync = async ({ application }: { application: string })
|
|
|
72
163
|
"must share the same @k2b/sync namespace.",
|
|
73
164
|
);
|
|
74
165
|
}
|
|
166
|
+
if (env.NATS_SERVERS.length === 0) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`NATS_SERVERS is not set (application "${application}"). @k2b/sync needs a comma-separated list of ` +
|
|
169
|
+
"nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
75
172
|
if (current || starting) throw new Error("A Sync instance is already starting or bound to this process");
|
|
76
173
|
starting = true;
|
|
77
174
|
try {
|
|
78
|
-
const connection = await
|
|
79
|
-
let sync: Sync | undefined;
|
|
80
|
-
try {
|
|
81
|
-
sync = createSync({
|
|
82
|
-
connection,
|
|
83
|
-
namespace: env.SYNC_NAMESPACE,
|
|
84
|
-
application,
|
|
85
|
-
defaults: { replicas: env.SYNC_REPLICAS },
|
|
86
|
-
observe: (event) => observeSyncEvent(event, application),
|
|
87
|
-
});
|
|
88
|
-
bindProcessSync(sync);
|
|
89
|
-
await sync.ready();
|
|
90
|
-
} catch (error) {
|
|
91
|
-
if (current === sync) unbindProcessSync();
|
|
92
|
-
await flushSyncTraceEvents();
|
|
93
|
-
await connection.close();
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
const active = sync;
|
|
175
|
+
const { sync: active, connection } = await waitForNats(() => connectReadySync(application), { application });
|
|
97
176
|
let stopPromise: Promise<void> | undefined;
|
|
98
177
|
return {
|
|
99
178
|
sync: active,
|
|
@@ -28,6 +28,9 @@ type SsrHandler<E extends Env, T extends object> = (context: Context<E & PageEnv
|
|
|
28
28
|
* (such as the request locale for `<html lang>`) are present on every SSR
|
|
29
29
|
* page without per-route plumbing. Redirects and other passthrough Responses
|
|
30
30
|
* skip it.
|
|
31
|
+
*
|
|
32
|
+
* Rendered documents default to `Cache-Control: private, no-store` unless the
|
|
33
|
+
* handler or a middleware already chose a policy.
|
|
31
34
|
*/
|
|
32
35
|
export const createStatusPreservingSsrHandler = <T extends object>(
|
|
33
36
|
html: HtmlFn<T>,
|
|
@@ -55,7 +58,12 @@ export const createStatusPreservingSsrHandler = <T extends object>(
|
|
|
55
58
|
response.headers.forEach((value, key) => {
|
|
56
59
|
headers[key] = value;
|
|
57
60
|
});
|
|
58
|
-
|
|
61
|
+
const document = context.newResponse(response.body, status as StatusCode, headers);
|
|
62
|
+
// A rendered document carries the signed-in user's state. Without an
|
|
63
|
+
// explicit policy, a browser may show it again on Back from its HTTP
|
|
64
|
+
// cache, even after sign-out. Handlers that set their own policy keep it.
|
|
65
|
+
if (!document.headers.has("Cache-Control")) document.headers.set("Cache-Control", "private, no-store");
|
|
66
|
+
return document;
|
|
59
67
|
});
|
|
60
68
|
};
|
|
61
69
|
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brings existing JetStream streams to the byte limits that the Sync jobs,
|
|
3
|
+
* queues, and topics of one Cloud process declare.
|
|
4
|
+
*
|
|
5
|
+
* JetStream reserves a stream's whole `max_bytes` on every replica as soon as
|
|
6
|
+
* the stream exists. @k2b/sync 7 declares small limits: a job or queue without
|
|
7
|
+
* `retention` holds 256 messages at its payload limit, and `deadLetterRetention`
|
|
8
|
+
* sizes dead letters apart from the work or event stream. Sync never
|
|
9
|
+
* reconfigures an existing stream, though. A stream created with other limits,
|
|
10
|
+
* such as the 1 GiB that Sync 6 gave every job and queue without `retention`,
|
|
11
|
+
* fails the declaration with `ResourceDriftError` on `max_bytes`.
|
|
12
|
+
*
|
|
13
|
+
* Before a job, queue, or topic is first used, this module therefore changes
|
|
14
|
+
* the byte limit of such a stream in place: both streams of a job or queue,
|
|
15
|
+
* and the dead-letter stream of a topic. A stream that holds more than its new
|
|
16
|
+
* limit would lose its oldest messages; it keeps its limit instead:
|
|
17
|
+
*
|
|
18
|
+
* - a job or queue is declared with the stream's current limit, so its work
|
|
19
|
+
* stays usable, and a later start applies the new limit;
|
|
20
|
+
* - a topic is declared synchronously, before its streams can be inspected,
|
|
21
|
+
* so its declaration keeps reporting the drift until the stream holds less.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
type DeadLetterStore,
|
|
25
|
+
type Job,
|
|
26
|
+
type JobConfig,
|
|
27
|
+
type Queue,
|
|
28
|
+
type QueueConfig,
|
|
29
|
+
ResourceDriftError,
|
|
30
|
+
type Sync,
|
|
31
|
+
type Topic,
|
|
32
|
+
type TopicConfig,
|
|
33
|
+
} from "@k2b/sync";
|
|
34
|
+
import { jetstreamManager, RetentionPolicy, type StreamInfo } from "@nats-io/jetstream";
|
|
35
|
+
import type { NatsConnection } from "@nats-io/transport-node";
|
|
36
|
+
import { logger } from "../services/logging";
|
|
37
|
+
|
|
38
|
+
/** Sync 7's limits for a job or queue declared without `retention`, which @k2b/sync does not export. */
|
|
39
|
+
const SYNC_PAYLOAD_BYTES = 128 * 1024;
|
|
40
|
+
const SYNC_DEAD_LETTER_HEADROOM_BYTES = 4096;
|
|
41
|
+
const SYNC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/** Byte limit Sync 7 gives a job or queue without `retention`: 256 messages at its payload limit, at most 1 GiB. */
|
|
44
|
+
export const syncDefaultMaxBytes = (maxPayloadBytes = SYNC_PAYLOAD_BYTES): number =>
|
|
45
|
+
Math.min(1024 ** 3, 256 * (maxPayloadBytes + SYNC_DEAD_LETTER_HEADROOM_BYTES));
|
|
46
|
+
|
|
47
|
+
const log = logger("sync:budget");
|
|
48
|
+
|
|
49
|
+
type Kind = "job" | "queue" | "topic";
|
|
50
|
+
type Limits = { work: number; deadLetters: number };
|
|
51
|
+
|
|
52
|
+
/** Memoizes a promise, forgetting a rejection so the next call retries. */
|
|
53
|
+
const once = <T>(create: () => Promise<T>): (() => Promise<T>) => {
|
|
54
|
+
let pending: Promise<T> | undefined;
|
|
55
|
+
return () =>
|
|
56
|
+
(pending ??= create().catch((error: unknown) => {
|
|
57
|
+
pending = undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
}));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The stream and declared byte limit when Sync refused a declaration only for an existing stream's byte limit. */
|
|
63
|
+
const byteLimitDrift = (error: unknown): { stream: string; maxBytes: number } | undefined => {
|
|
64
|
+
if (!(error instanceof ResourceDriftError) || error.differences.length !== 1) return undefined;
|
|
65
|
+
const [difference] = error.differences;
|
|
66
|
+
if (difference?.field !== "max_bytes" || typeof difference.declared !== "number") return undefined;
|
|
67
|
+
return { stream: error.resource, maxBytes: difference.declared };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Byte limits a job or queue declares for its work and dead-letter streams. */
|
|
71
|
+
const declaredLimits = (config: QueueConfig | JobConfig): Limits => {
|
|
72
|
+
const work = config.retention?.maxBytes ?? syncDefaultMaxBytes(config.maxPayloadBytes);
|
|
73
|
+
return { work, deadLetters: config.deadLetterRetention?.maxBytes ?? work };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The declaration with the given limits, or unchanged when they are its own. */
|
|
77
|
+
const withLimits = <Config extends QueueConfig | JobConfig>(config: Config, target: Limits, limits: Limits): Config =>
|
|
78
|
+
limits.work === target.work && limits.deadLetters === target.deadLetters
|
|
79
|
+
? config
|
|
80
|
+
: {
|
|
81
|
+
...config,
|
|
82
|
+
retention: { maxAgeMs: SYNC_MAX_AGE_MS, ...config.retention, maxBytes: limits.work },
|
|
83
|
+
deadLetterRetention: { ...config.deadLetterRetention, maxBytes: limits.deadLetters },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const createStreamLimits = (connection: NatsConnection, namespace: string) => {
|
|
87
|
+
const manager = once(() => jetstreamManager(connection));
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Work and dead-letter streams per job and queue of this namespace as they
|
|
91
|
+
* are now. Concurrent callers share one listing; a later call lists again,
|
|
92
|
+
* so streams that another process created or changed since are seen.
|
|
93
|
+
*/
|
|
94
|
+
let listing: Promise<Map<string, StreamInfo[]>> | undefined;
|
|
95
|
+
const list = () =>
|
|
96
|
+
(listing ??= (async () => {
|
|
97
|
+
const jsm = await manager();
|
|
98
|
+
const streams = new Map<string, StreamInfo[]>();
|
|
99
|
+
for await (const info of jsm.streams.list()) {
|
|
100
|
+
const metadata = info.config.metadata;
|
|
101
|
+
const kind = metadata?.["sync.kind"];
|
|
102
|
+
if (metadata?.["sync.namespace"] !== namespace || metadata["sync.managed"] !== "true") continue;
|
|
103
|
+
// A job's coalescing claims live in a KV bucket without a byte limit.
|
|
104
|
+
if ((kind !== "job" && kind !== "queue") || info.config.name.startsWith("KV_")) continue;
|
|
105
|
+
const key = `${kind}:${metadata["sync.id"]}`;
|
|
106
|
+
streams.set(key, [...(streams.get(key) ?? []), info]);
|
|
107
|
+
}
|
|
108
|
+
return streams;
|
|
109
|
+
})().finally(() => {
|
|
110
|
+
listing = undefined;
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Limits a job or queue declares: its own, except for an existing stream
|
|
115
|
+
* that holds more than its new limit, which keeps its current limit.
|
|
116
|
+
*/
|
|
117
|
+
const keep = async (kind: Kind, id: string, target: Limits): Promise<Limits> => {
|
|
118
|
+
const streams = (await list()).get(`${kind}:${id}`) ?? [];
|
|
119
|
+
const kept = (info: StreamInfo | undefined, maxBytes: number): number => {
|
|
120
|
+
if (!info || info.config.max_bytes === maxBytes || info.state.bytes <= maxBytes) return maxBytes;
|
|
121
|
+
log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
|
|
122
|
+
kind,
|
|
123
|
+
id,
|
|
124
|
+
stream: info.config.name,
|
|
125
|
+
limit: info.config.max_bytes,
|
|
126
|
+
target: maxBytes,
|
|
127
|
+
held: info.state.bytes,
|
|
128
|
+
});
|
|
129
|
+
return info.config.max_bytes;
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
work: kept(
|
|
133
|
+
streams.find((info) => info.config.retention === RetentionPolicy.Workqueue),
|
|
134
|
+
target.work,
|
|
135
|
+
),
|
|
136
|
+
deadLetters: kept(
|
|
137
|
+
streams.find((info) => info.config.retention !== RetentionPolicy.Workqueue),
|
|
138
|
+
target.deadLetters,
|
|
139
|
+
),
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Streams reported as too full to lower; a topic retries on every use, but logs once. */
|
|
144
|
+
const reported = new Set<string>();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Provisions a declaration. Each existing stream whose byte limit differs
|
|
148
|
+
* from the declared one gets the declared limit if it holds no more; then
|
|
149
|
+
* the declaration is tried again. Each stream is changed at most once. Of a
|
|
150
|
+
* topic, only the dead-letter stream, whose subjects end in `.dlq.>`, is
|
|
151
|
+
* changed: its event stream keeps Sync's drift check, so applications that
|
|
152
|
+
* declare one topic differently still fail instead of overwriting each
|
|
153
|
+
* other's limit.
|
|
154
|
+
*/
|
|
155
|
+
const adopt = async (kind: Kind, id: string, ready: () => Promise<void>): Promise<void> => {
|
|
156
|
+
const changed = new Set<string>();
|
|
157
|
+
for (;;) {
|
|
158
|
+
try {
|
|
159
|
+
return await ready();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const drift = byteLimitDrift(error);
|
|
162
|
+
if (!drift || changed.has(drift.stream)) throw error;
|
|
163
|
+
changed.add(drift.stream);
|
|
164
|
+
const jsm = await manager();
|
|
165
|
+
const info = await jsm.streams.info(drift.stream);
|
|
166
|
+
if (kind === "topic" && !info.config.subjects.some((subject) => subject.endsWith(".dlq.>"))) throw error;
|
|
167
|
+
if (info.state.bytes > drift.maxBytes) {
|
|
168
|
+
if (reported.has(drift.stream)) throw error;
|
|
169
|
+
reported.add(drift.stream);
|
|
170
|
+
log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
|
|
171
|
+
kind,
|
|
172
|
+
id,
|
|
173
|
+
stream: drift.stream,
|
|
174
|
+
limit: info.config.max_bytes,
|
|
175
|
+
target: drift.maxBytes,
|
|
176
|
+
held: info.state.bytes,
|
|
177
|
+
});
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
await jsm.streams.update(drift.stream, { max_bytes: drift.maxBytes });
|
|
181
|
+
log.info("Changed the byte limit of a Sync stream", {
|
|
182
|
+
kind,
|
|
183
|
+
id,
|
|
184
|
+
stream: drift.stream,
|
|
185
|
+
from: info.config.max_bytes,
|
|
186
|
+
to: drift.maxBytes,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return { keep, adopt };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const deferDeadLetters = <T>(resolve: () => Promise<{ deadLetters: DeadLetterStore<T> }>): DeadLetterStore<T> => ({
|
|
196
|
+
page: async (options) => (await resolve()).deadLetters.page(options),
|
|
197
|
+
get: async (input) => (await resolve()).deadLetters.get(input),
|
|
198
|
+
list: async (options) => (await resolve()).deadLetters.list(options),
|
|
199
|
+
requeue: async (input) => (await resolve()).deadLetters.requeue(input),
|
|
200
|
+
delete: async (input) => (await resolve()).deadLetters.delete(input),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const deferJob = <Input>(resolve: () => Promise<Job<Input>>): Job<Input> => ({
|
|
204
|
+
ready: async () => (await resolve()).ready(),
|
|
205
|
+
submit: async (job) => (await resolve()).submit(job),
|
|
206
|
+
submitMany: async (jobs, options) => (await resolve()).submitMany(jobs, options),
|
|
207
|
+
submitBatch: async (jobs) => (await resolve()).submitBatch(jobs),
|
|
208
|
+
pause: async (options) => (await resolve()).pause(options),
|
|
209
|
+
resume: async () => (await resolve()).resume(),
|
|
210
|
+
process: async (options, handler) => (await resolve()).process(options, handler),
|
|
211
|
+
deadLetters: deferDeadLetters(resolve),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const deferQueue = <T>(resolve: () => Promise<Queue<T>>): Queue<T> => ({
|
|
215
|
+
ready: async () => (await resolve()).ready(),
|
|
216
|
+
send: async (message) => (await resolve()).send(message),
|
|
217
|
+
sendBatch: async (messages) => (await resolve()).sendBatch(messages),
|
|
218
|
+
pause: async (options) => (await resolve()).pause(options),
|
|
219
|
+
resume: async () => (await resolve()).resume(),
|
|
220
|
+
process: async (options, handler) => (await resolve()).process(options, handler),
|
|
221
|
+
reader: async (options) => (await resolve()).reader(options),
|
|
222
|
+
deadLetters: deferDeadLetters(resolve),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** A declared topic whose every provisioning use waits until `ready` has adopted its streams. */
|
|
226
|
+
const deferTopic = <T>(topic: Topic<T>, ready: () => Promise<void>): Topic<T> => {
|
|
227
|
+
const whenReady = <Result>(use: () => Promise<Result>): Promise<Result> => ready().then(use);
|
|
228
|
+
const iterateWhenReady = async function* <Event>(open: () => AsyncIterable<Event>): AsyncIterable<Event> {
|
|
229
|
+
await ready();
|
|
230
|
+
yield* open();
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
ready,
|
|
234
|
+
publish: (input) => whenReady(() => topic.publish(input)),
|
|
235
|
+
publishBatch: (input) => whenReady(() => topic.publishBatch(input)),
|
|
236
|
+
hub: (options) => {
|
|
237
|
+
const hub = topic.hub(options);
|
|
238
|
+
return { subscribe: (subscription) => iterateWhenReady(() => hub.subscribe(subscription)), close: () => hub.close() };
|
|
239
|
+
},
|
|
240
|
+
cursorSequence: (cursor) => topic.cursorSequence(cursor),
|
|
241
|
+
cursorAt: (sequence) => topic.cursorAt(sequence),
|
|
242
|
+
pauseConsumer: (input) => whenReady(() => topic.pauseConsumer(input)),
|
|
243
|
+
resumeConsumer: (input) => whenReady(() => topic.resumeConsumer(input)),
|
|
244
|
+
latestCursor: (options) => whenReady(() => topic.latestCursor(options)),
|
|
245
|
+
head: () => whenReady(() => topic.head()),
|
|
246
|
+
live: (options) => iterateWhenReady(() => topic.live(options)),
|
|
247
|
+
replay: (options) => iterateWhenReady(() => topic.replay(options)),
|
|
248
|
+
follow: (options) => iterateWhenReady(() => topic.follow(options)),
|
|
249
|
+
destroy: () => topic.destroy(),
|
|
250
|
+
process: (options, handler) => whenReady(() => topic.process(options, handler)),
|
|
251
|
+
deadLetters: {
|
|
252
|
+
list: (options) => whenReady(() => topic.deadLetters.list(options)),
|
|
253
|
+
get: (input) => whenReady(() => topic.deadLetters.get(input)),
|
|
254
|
+
delete: (input) => whenReady(() => topic.deadLetters.delete(input)),
|
|
255
|
+
replay: (input) => whenReady(() => topic.deadLetters.replay(input)),
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The process Sync whose jobs, queues, and topics adopt the byte limits of
|
|
262
|
+
* their existing streams before first use. A job or queue is declared to Sync
|
|
263
|
+
* on its first use or on `ready()`; a topic is declared at once. Every other
|
|
264
|
+
* primitive is Sync's own.
|
|
265
|
+
*/
|
|
266
|
+
export const withSyncBudgets = (sync: Sync, { connection, namespace }: { connection: NatsConnection; namespace: string }): Sync => {
|
|
267
|
+
const { keep, adopt } = createStreamLimits(connection, namespace);
|
|
268
|
+
/** Every job, queue, and topic declared so far, so `ready()` provisions them like Sync does. */
|
|
269
|
+
const declared = new Map<string, () => Promise<unknown>>();
|
|
270
|
+
|
|
271
|
+
const declareWork = <Config extends QueueConfig | JobConfig, Handle extends { ready(): Promise<void> }>(
|
|
272
|
+
kind: "job" | "queue",
|
|
273
|
+
config: Config,
|
|
274
|
+
create: (config: Config) => Handle,
|
|
275
|
+
): (() => Promise<Handle>) => {
|
|
276
|
+
// Declared once: Sync refuses a second declaration with other limits, so a
|
|
277
|
+
// failed first use retries provisioning with the limits chosen first.
|
|
278
|
+
const declaration = once(async () => {
|
|
279
|
+
const target = declaredLimits(config);
|
|
280
|
+
return create(withLimits(config, target, await keep(kind, config.id, target)));
|
|
281
|
+
});
|
|
282
|
+
const resolve = once(async () => {
|
|
283
|
+
const handle = await declaration();
|
|
284
|
+
await adopt(kind, config.id, () => handle.ready());
|
|
285
|
+
return handle;
|
|
286
|
+
});
|
|
287
|
+
declared.set(`${kind}:${config.id}`, resolve);
|
|
288
|
+
return resolve;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
...sync,
|
|
293
|
+
ready: async () => {
|
|
294
|
+
await Promise.all([...declared.values()].map((resolve) => resolve()));
|
|
295
|
+
await sync.ready();
|
|
296
|
+
},
|
|
297
|
+
job: <Input>(config: JobConfig) => deferJob(declareWork("job", config, (declaration) => sync.job<Input>(declaration))),
|
|
298
|
+
queue: <T>(config: QueueConfig) => deferQueue(declareWork("queue", config, (declaration) => sync.queue<T>(declaration))),
|
|
299
|
+
topic: <T>(config: TopicConfig) => {
|
|
300
|
+
const topic = sync.topic<T>(config);
|
|
301
|
+
const ready = once(() => adopt("topic", config.id, () => topic.ready()));
|
|
302
|
+
declared.set(`topic:${config.id}`, ready);
|
|
303
|
+
return deferTopic(topic, ready);
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
};
|