@cosmicdrift/kumiko-server-runtime 0.413.0 → 0.414.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/apply-boot-seeds.js +0 -1
- package/dist/boot/search-reindex-on-boot.d.ts +1 -1
- package/dist/boot/search-reindex-on-boot.js +6 -3
- package/dist/boot/shared-boot-steps.d.ts +1 -0
- package/dist/boot/shared-boot-steps.js +4 -1
- package/dist/boot/stream-tenant-backfill-on-boot.js +2 -2
- package/dist/boot/wait-for-database.js +2 -5
- package/dist/boot/worker-boot-core.js +17 -9
- package/dist/boot-options/billing-option.d.ts +5 -2
- package/dist/boot-options/billing-option.js +3 -4
- package/dist/boot-options/file-storage-option.js +14 -1
- package/dist/boot-options/process-signals.d.ts +2 -0
- package/dist/boot-options/process-signals.js +7 -0
- package/dist/pwa-service-worker-script.d.ts +9 -0
- package/dist/pwa-service-worker-script.js +119 -0
- package/dist/pwa.d.ts +32 -0
- package/dist/pwa.js +134 -0
- package/dist/run-prod-app-boot-context.js +11 -6
- package/dist/run-prod-app-connections.d.ts +2 -1
- package/dist/run-prod-app-connections.js +2 -2
- package/dist/run-prod-app-handle.js +10 -4
- package/dist/run-prod-app-pwa.d.ts +2 -0
- package/dist/run-prod-app-pwa.js +41 -0
- package/dist/run-prod-app-seeds.js +0 -1
- package/dist/run-prod-app-static-files.d.ts +2 -1
- package/dist/run-prod-app-static-files.js +9 -1
- package/dist/run-prod-app.d.ts +6 -0
- package/dist/run-prod-app.js +2 -2
- package/dist/run-worker-app.js +2 -3
- package/package.json +15 -7
- package/src/changes.json +18 -0
|
@@ -6,7 +6,6 @@ import { runInSystemRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/
|
|
|
6
6
|
// missing call site (e.g. someone deletes the line from runDevApp)
|
|
7
7
|
// surfaces as a missing-helper-use in code review rather than silently
|
|
8
8
|
// shipping a server that never seeds.
|
|
9
|
-
// @wrapper-known semantic-alias
|
|
10
9
|
export async function applyBootSeeds(deps) {
|
|
11
10
|
return runInSystemRowLevelSecurityScope("boot seeds write rows for arbitrary tenants", async () => {
|
|
12
11
|
await seedAllConfigValues(deps.registry, deps.db, deps.cipher);
|
|
@@ -12,7 +12,7 @@ export type SearchReindexOutcome = {
|
|
|
12
12
|
readonly reindexedEntities: readonly string[];
|
|
13
13
|
readonly failedEntities: readonly string[];
|
|
14
14
|
};
|
|
15
|
-
/** Changes when the search fields
|
|
15
|
+
/** Changes when the search fields, the payload extension code or the document format change. */
|
|
16
16
|
export declare function searchReindexFingerprint(registry: Registry, entityName: string): string;
|
|
17
17
|
export declare function runSearchReindexOnBoot(opts: {
|
|
18
18
|
readonly db: DbConnection;
|
|
@@ -16,12 +16,15 @@ function searchedEntityNames(registry) {
|
|
|
16
16
|
return [...registry.getAllEntities().keys()].filter((name) => registry.getSearchableFields(name).length > 0 ||
|
|
17
17
|
registry.getSearchPayloadExtensions(name).length > 0);
|
|
18
18
|
}
|
|
19
|
-
/** Changes when the search fields
|
|
19
|
+
/** Changes when the search fields, the payload extension code or the document format change. */
|
|
20
20
|
export function searchReindexFingerprint(registry, entityName) {
|
|
21
21
|
const fields = [...registry.getSearchableFields(entityName)].sort();
|
|
22
|
-
|
|
22
|
+
// Contributors have no name or version; their source is what decides what they add to a document.
|
|
23
|
+
const extensions = registry
|
|
24
|
+
.getSearchPayloadExtensions(entityName)
|
|
25
|
+
.map((contribute) => createHash("sha256").update(contribute.toString()).digest("hex"));
|
|
23
26
|
return createHash("sha256")
|
|
24
|
-
.update(`${SEARCH_DOCUMENT_FORMAT_VERSION}|${fields.join(",")}|${
|
|
27
|
+
.update(`${SEARCH_DOCUMENT_FORMAT_VERSION}|${fields.join(",")}|${extensions.join(",")}`)
|
|
25
28
|
.digest("hex")
|
|
26
29
|
.slice(0, 16);
|
|
27
30
|
}
|
|
@@ -13,6 +13,7 @@ import { type DatabaseWaitClock } from "./wait-for-database.ts";
|
|
|
13
13
|
/** Returns `envSource` itself (it may be `process.env`) when there is nothing to layer under it. */
|
|
14
14
|
export declare function withEnvDefaults(envDefaults: Readonly<Record<string, string | undefined>>, envSource: Record<string, string | undefined>): Record<string, string | undefined>;
|
|
15
15
|
export declare function composeAppFeatures(appFeatures: readonly FeatureDefinition[], bootFeatures: readonly FeatureDefinition[], effectiveAuth: AuthOptionsCarrier | undefined, bundledLists?: BundledListsOptions): readonly FeatureDefinition[];
|
|
16
|
+
export declare function validateBootAndWarnTz(features: readonly FeatureDefinition[], bootOptions: ValidateBootOptions): void;
|
|
16
17
|
export declare function validateComposedFeatures(features: readonly FeatureDefinition[], bootOptions: ValidateBootOptions): void;
|
|
17
18
|
export declare function assertMfaSwitchTenantGate(registry: Registry, processName: string): void;
|
|
18
19
|
export declare function buildEffectiveFeaturesResolver(deps: {
|
|
@@ -23,9 +23,12 @@ export function composeAppFeatures(appFeatures, bootFeatures, effectiveAuth, bun
|
|
|
23
23
|
...(bundledLists && { bundledLists }),
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
-
export function
|
|
26
|
+
export function validateBootAndWarnTz(features, bootOptions) {
|
|
27
27
|
validateBoot(features, bootOptions);
|
|
28
28
|
warnIfNonUtcServerTimeZone();
|
|
29
|
+
}
|
|
30
|
+
export function validateComposedFeatures(features, bootOptions) {
|
|
31
|
+
validateBootAndWarnTz(features, bootOptions);
|
|
29
32
|
validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
|
|
30
33
|
}
|
|
31
34
|
export function assertMfaSwitchTenantGate(registry, processName) {
|
|
@@ -19,6 +19,7 @@ import { deleteMany, fetchOne, insertOne, listEventTenantIds, withSessionAdvisor
|
|
|
19
19
|
import { createEsOperationsTable, esOperationsTable } from "@cosmicdrift/kumiko-framework/es-ops";
|
|
20
20
|
import { runInSystemRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
|
|
21
21
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
22
|
+
import { isEntitySearchable } from "@cosmicdrift/kumiko-framework/search";
|
|
22
23
|
import { waitUntilDatabaseReachable } from "./wait-for-database.js";
|
|
23
24
|
export const STREAM_TENANT_BACKFILL_LOCK_KEY = 0x73_74_62_66; // 'stbf'
|
|
24
25
|
export function streamBackfillPendingId(part) {
|
|
@@ -70,8 +71,7 @@ function isDeferredWithoutSearchAdapter(searchable, deps) {
|
|
|
70
71
|
async function runPart(part, deps) {
|
|
71
72
|
const { db, registry, log } = deps;
|
|
72
73
|
const entity = part === "user" ? "user" : "tenant";
|
|
73
|
-
const searchable = registry
|
|
74
|
-
registry.getSearchPayloadExtensions(entity).length > 0;
|
|
74
|
+
const searchable = isEntitySearchable(registry, entity);
|
|
75
75
|
const blockedWithoutAdapter = isDeferredWithoutSearchAdapter(searchable, deps);
|
|
76
76
|
const warnBlocked = () => log.warn(`stream tenant backfill (${part}) has work but "${entity}" is searchable and this process has no search adapter; a process with one runs it`);
|
|
77
77
|
const pendingId = streamBackfillPendingId(part);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractPgError, tableExists } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { BUN_SQL_CONNECTION_ERROR_CODES, extractPgError, tableExists, } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
const TRANSIENT_CODES = new Set([
|
|
3
3
|
"53300", // too_many_connections
|
|
4
4
|
"57P03", // cannot_connect_now (startup / recovery)
|
|
@@ -7,10 +7,7 @@ const TRANSIENT_CODES = new Set([
|
|
|
7
7
|
"ETIMEDOUT",
|
|
8
8
|
"ENOTFOUND",
|
|
9
9
|
"EAI_AGAIN",
|
|
10
|
-
|
|
11
|
-
"ERR_POSTGRES_CONNECTION_REFUSED",
|
|
12
|
-
"ERR_POSTGRES_CONNECTION_TIMEOUT",
|
|
13
|
-
"ERR_POSTGRES_CONNECTION_FAILED",
|
|
10
|
+
...BUN_SQL_CONNECTION_ERROR_CODES,
|
|
14
11
|
]);
|
|
15
12
|
const CONNECTION_EXCEPTION_CLASS = "08";
|
|
16
13
|
export function isTransientConnectionError(error) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { createDefaultSseBroker, loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
|
|
7
7
|
import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
8
|
import { assertRowLevelSecurityBootState, configureEntityFieldEncryption, knownTablesOf, } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
-
import { createRegistry,
|
|
9
|
+
import { createRegistry, } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
10
|
import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
11
11
|
import { DEFAULT_ROW_LEVEL_SECURITY_MODE } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
|
|
12
12
|
import { maintenanceWindowFromEnv } from "@cosmicdrift/kumiko-framework/jobs";
|
|
@@ -14,7 +14,6 @@ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
|
14
14
|
import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
|
|
15
15
|
import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
16
16
|
import { createRedisCacheSyncBus, redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
|
|
17
|
-
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
18
17
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
19
18
|
import { Redis } from "ioredis";
|
|
20
19
|
import { readShutdownDrainTimeoutMs } from "../boot-options/process-signals.js";
|
|
@@ -25,7 +24,7 @@ import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext
|
|
|
25
24
|
import { resolveBootCrypto } from "./boot-crypto.js";
|
|
26
25
|
import { jobRunLoggerCallbacks } from "./job-run-logger.js";
|
|
27
26
|
import { INSTANCE_ID_ENV } from "./record-app-started-on-boot.js";
|
|
28
|
-
import { buildEffectiveFeaturesResolver, openDatabasePools, waitForKmsHealthy, } from "./shared-boot-steps.js";
|
|
27
|
+
import { buildEffectiveFeaturesResolver, openDatabasePools, validateBootAndWarnTz, waitForKmsHealthy, } from "./shared-boot-steps.js";
|
|
29
28
|
import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase } from "./wait-for-database.js";
|
|
30
29
|
import { warnIfRowLevelSecurityOff } from "./warn-row-level-security-off.js";
|
|
31
30
|
import { assertWorkerMetricsOptions } from "./worker-metrics-server.js";
|
|
@@ -43,12 +42,12 @@ export async function resolveWorkerEnvSource(options, processName) {
|
|
|
43
42
|
})
|
|
44
43
|
: rawEnvSource;
|
|
45
44
|
}
|
|
46
|
-
async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
|
|
45
|
+
async function assertWorkerSchemaCurrent(db, migrationsDir, processName, bootedFeatures) {
|
|
47
46
|
const dir = migrationsDir ?? "./kumiko/migrations";
|
|
48
47
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint
|
|
49
48
|
console.log(`[${processName}] checking schema drift (${dir})…`);
|
|
50
49
|
try {
|
|
51
|
-
await assertKumikoSchemaCurrent(db, dir);
|
|
50
|
+
await assertKumikoSchemaCurrent(db, dir, bootedFeatures);
|
|
52
51
|
}
|
|
53
52
|
catch (err) {
|
|
54
53
|
if (err instanceof SchemaDriftError) {
|
|
@@ -58,6 +57,16 @@ async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
|
|
|
58
57
|
throw err;
|
|
59
58
|
}
|
|
60
59
|
}
|
|
60
|
+
// The broker is opened before the entrypoint; a throwing entrypoint would otherwise leak its connection.
|
|
61
|
+
async function createWorkerEntrypointOrCloseBroker(ownedBroker, args) {
|
|
62
|
+
try {
|
|
63
|
+
return createWorkerEntrypoint(args);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
await ownedBroker?.close();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
61
70
|
export async function bootWorkerProcess(options, envSource, profile) {
|
|
62
71
|
const { processName } = profile;
|
|
63
72
|
// Polyfill before anything else: without it every job fails with
|
|
@@ -79,12 +88,11 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
79
88
|
...(options.bundledLists && { bundledLists: options.bundledLists }),
|
|
80
89
|
});
|
|
81
90
|
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
82
|
-
|
|
91
|
+
validateBootAndWarnTz(features, {
|
|
83
92
|
env: envSource,
|
|
84
93
|
...(bootCrypto.entityFieldCipher && { entityFieldCipher: bootCrypto.entityFieldCipher }),
|
|
85
94
|
...options.validateBootOptions,
|
|
86
95
|
});
|
|
87
|
-
warnIfNonUtcServerTimeZone();
|
|
88
96
|
assertWorkerMetricsOptions(options.metrics, options.observability, processName);
|
|
89
97
|
assertPiiBootInvariants(features, {
|
|
90
98
|
kms: options.kms,
|
|
@@ -142,7 +150,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
142
150
|
});
|
|
143
151
|
if (options.migrations !== false) {
|
|
144
152
|
try {
|
|
145
|
-
await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName);
|
|
153
|
+
await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName, features);
|
|
146
154
|
}
|
|
147
155
|
catch (err) {
|
|
148
156
|
await closeDb();
|
|
@@ -192,7 +200,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
192
200
|
const extraContext = addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry);
|
|
193
201
|
const jobLogger = jobRunLoggerCallbacks(registry, db);
|
|
194
202
|
const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
|
|
195
|
-
const entrypoint =
|
|
203
|
+
const entrypoint = await createWorkerEntrypointOrCloseBroker(ownedRedisSseBroker, {
|
|
196
204
|
sseBroker,
|
|
197
205
|
registry,
|
|
198
206
|
rowLevelSecurity,
|
|
@@ -8,7 +8,7 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
8
8
|
* (`tier-engine`). Needs `billing-foundation`, `tier-engine`, `config` and
|
|
9
9
|
* `secrets` in `features`. The env keys (`STRIPE_API_KEY` /
|
|
10
10
|
* `STRIPE_WEBHOOK_SECRET`, `PADDLE_API_KEY` / `PADDLE_WEBHOOK_SECRET` /
|
|
11
|
-
* `
|
|
11
|
+
* `PADDLE_CLIENT_TOKEN`) are only the bridge fallback; the keys
|
|
12
12
|
* normally live in the secrets store.
|
|
13
13
|
*
|
|
14
14
|
* @example
|
|
@@ -22,15 +22,18 @@ export type BillingBootOption = {
|
|
|
22
22
|
/** At least one of `stripe` and `paddle` is required. */
|
|
23
23
|
readonly stripe?: {
|
|
24
24
|
readonly priceToTier: Readonly<Record<string, string>>;
|
|
25
|
-
/**
|
|
25
|
+
/** @deprecated Use `billing.webhookPath`; still read as a fallback. */
|
|
26
26
|
readonly webhookPath?: string;
|
|
27
27
|
readonly oneOffPriceIds?: SubscriptionStripeOptions["oneOffPriceIds"];
|
|
28
28
|
readonly paymentInvoiceCreation?: boolean;
|
|
29
|
+
readonly clientConfig?: SubscriptionStripeOptions["clientConfig"];
|
|
29
30
|
};
|
|
30
31
|
readonly paddle?: {
|
|
31
32
|
readonly priceToTier: Readonly<Record<string, string>>;
|
|
32
33
|
readonly oneOffPriceIds?: SubscriptionPaddleOptions["oneOffPriceIds"];
|
|
33
34
|
};
|
|
35
|
+
/** Default `SUBSCRIPTION_WEBHOOK_PATH`; must contain `:providerName`. Applies to every provider. */
|
|
36
|
+
readonly webhookPath?: string;
|
|
34
37
|
/** Tier a tenant falls back to without an active subscription. */
|
|
35
38
|
readonly defaultTier: string;
|
|
36
39
|
/** Valid tier names beyond `defaultTier` and the `priceToTier` targets. */
|
|
@@ -23,18 +23,17 @@ function providerFeatures({ stripe, paddle }, envSource) {
|
|
|
23
23
|
...(stripe.paymentInvoiceCreation !== undefined && {
|
|
24
24
|
paymentInvoiceCreation: stripe.paymentInvoiceCreation,
|
|
25
25
|
}),
|
|
26
|
+
...(stripe.clientConfig && { clientConfig: stripe.clientConfig }),
|
|
26
27
|
...bridgeCredentials(envSource, "STRIPE_API_KEY", "STRIPE_WEBHOOK_SECRET"),
|
|
27
28
|
}));
|
|
28
29
|
}
|
|
29
30
|
if (paddle) {
|
|
30
|
-
const tenantBindingKey = envSource["PADDLE_TENANT_BINDING_KEY"];
|
|
31
31
|
const clientToken = envSource["PADDLE_CLIENT_TOKEN"];
|
|
32
32
|
warnOnEmptyPriceMap("paddle", paddle.priceToTier);
|
|
33
33
|
features.push(createSubscriptionPaddleFeature({
|
|
34
34
|
priceToTier: paddle.priceToTier,
|
|
35
35
|
...(paddle.oneOffPriceIds && { oneOffPriceIds: paddle.oneOffPriceIds }),
|
|
36
36
|
...bridgeCredentials(envSource, "PADDLE_API_KEY", "PADDLE_WEBHOOK_SECRET"),
|
|
37
|
-
...(tenantBindingKey && { tenantBindingKey }),
|
|
38
37
|
...(clientToken && { clientToken }),
|
|
39
38
|
}));
|
|
40
39
|
}
|
|
@@ -45,9 +44,9 @@ export function resolveBillingOption(billing, envSource) {
|
|
|
45
44
|
if (!stripe && !paddle) {
|
|
46
45
|
throw new Error("[runProdApp] billing needs at least one provider: set billing.stripe or billing.paddle.");
|
|
47
46
|
}
|
|
48
|
-
const webhookPath = stripe?.webhookPath ?? SUBSCRIPTION_WEBHOOK_PATH;
|
|
47
|
+
const webhookPath = billing.webhookPath ?? stripe?.webhookPath ?? SUBSCRIPTION_WEBHOOK_PATH;
|
|
49
48
|
if (!webhookPath.includes(PROVIDER_NAME_PARAM)) {
|
|
50
|
-
throw new Error(`[runProdApp] billing.
|
|
49
|
+
throw new Error(`[runProdApp] billing.webhookPath "${webhookPath}" must contain ${PROVIDER_NAME_PARAM}.`);
|
|
51
50
|
}
|
|
52
51
|
const features = providerFeatures(billing, envSource);
|
|
53
52
|
const tierNames = new Set([
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createFileProviderInMemoryFeature } from "@cosmicdrift/kumiko-bundled-features/file-provider-inmemory";
|
|
2
|
-
import { createFileProviderS3Feature, createFileProviderS3NamedEnvFeature, } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
|
|
2
|
+
import { createFileProviderS3Feature, createFileProviderS3NamedEnvFeature, isAcceptableS3Endpoint, } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
|
|
3
3
|
import { FILE_STORAGE_PROVIDER_ENV, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
export function isNamedS3FileProvider(provider) {
|
|
5
5
|
return typeof provider === "object";
|
|
@@ -32,6 +32,15 @@ function assertNamedProviderNames(named) {
|
|
|
32
32
|
seen.add(name);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
function assertEndpointsAcceptable(envSource, isProduction, providers) {
|
|
36
|
+
for (const { label, envPrefix } of providers) {
|
|
37
|
+
const key = `${envPrefix}ENDPOINT`;
|
|
38
|
+
const endpoint = envSource[key];
|
|
39
|
+
if (endpoint && !isAcceptableS3Endpoint(endpoint, isProduction)) {
|
|
40
|
+
throw new Error(`[runProdApp] fileStorage ${label}: ${key} must use https unless the host is loopback (NODE_ENV=production).`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
35
44
|
function buildProviderFeature(provider) {
|
|
36
45
|
return provider === "s3-env"
|
|
37
46
|
? createFileProviderS3Feature({ fromEnv: true })
|
|
@@ -67,6 +76,10 @@ export function resolveFileStorageOption(fileStorage, deps) {
|
|
|
67
76
|
}
|
|
68
77
|
const named = additional.filter(isNamedS3FileProvider);
|
|
69
78
|
assertNamedProviderNames(named);
|
|
79
|
+
assertEndpointsAcceptable(deps.envSource, deps.isProduction, [
|
|
80
|
+
...(wanted.has("s3-env") ? [{ label: '"s3-env"', envPrefix: "S3_" }] : []),
|
|
81
|
+
...named.map((entry) => ({ label: `provider "${entry.name}"`, envPrefix: entry.envPrefix })),
|
|
82
|
+
]);
|
|
70
83
|
for (const entry of named) {
|
|
71
84
|
const missing = entry.lazy ? [] : missingEnvKeys(deps.envSource, s3KeysOf(entry.envPrefix));
|
|
72
85
|
if (missing.length > 0) {
|
|
@@ -11,6 +11,8 @@ export declare const SHUTDOWN_DRAIN_TIMEOUT_ENV = "KUMIKO_SHUTDOWN_DRAIN_TIMEOUT
|
|
|
11
11
|
export declare function readShutdownDrainTimeoutMs(env: Readonly<Record<string, string | undefined>>): number | undefined;
|
|
12
12
|
/** Explicit `signals.drainTimeoutMs` wins over the env variable, which wins over the default. */
|
|
13
13
|
export declare function resolveDrainTimeoutMs(explicit: number | undefined, fromEnv: number | undefined): number;
|
|
14
|
+
export declare const SHUTDOWN_TAIL_RESERVE_MS = 5000;
|
|
15
|
+
export declare function jobDrainBudgetMs(totalBudgetMs: number): number;
|
|
14
16
|
export type SignalProcess = {
|
|
15
17
|
on(signal: ShutdownSignal, listener: () => void): unknown;
|
|
16
18
|
off(signal: ShutdownSignal, listener: () => void): unknown;
|
|
@@ -18,6 +18,13 @@ export function readShutdownDrainTimeoutMs(env) {
|
|
|
18
18
|
export function resolveDrainTimeoutMs(explicit, fromEnv) {
|
|
19
19
|
return explicit ?? fromEnv ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
20
20
|
}
|
|
21
|
+
// HTTP shutdown, escape-hatch audit flush and DB/Redis close run after the job drain and must fit
|
|
22
|
+
// into the same signal budget, so the job drain gets the budget minus this reserve.
|
|
23
|
+
export const SHUTDOWN_TAIL_RESERVE_MS = 5_000;
|
|
24
|
+
const MIN_JOB_DRAIN_MS = 1_000;
|
|
25
|
+
export function jobDrainBudgetMs(totalBudgetMs) {
|
|
26
|
+
return Math.max(MIN_JOB_DRAIN_MS, totalBudgetMs - SHUTDOWN_TAIL_RESERVE_MS);
|
|
27
|
+
}
|
|
21
28
|
/** Registers SIGTERM/SIGINT: the first signal drains via `stop()` and exits
|
|
22
29
|
* 0, a drain that fails or outlives the timeout exits 1. Returns the
|
|
23
30
|
* uninstall function so `stop()` can drop the listeners again. */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const PWA_CACHE_NAME_PREFIX = "kumiko-pwa-";
|
|
2
|
+
export type PwaServiceWorkerScriptInput = {
|
|
3
|
+
readonly cacheName: string;
|
|
4
|
+
readonly cacheScopePrefix: string;
|
|
5
|
+
readonly precacheUrls: readonly string[];
|
|
6
|
+
readonly shellUrl: string;
|
|
7
|
+
readonly serviceWorkerPath: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function buildPwaServiceWorkerScript(input: PwaServiceWorkerScriptInput): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The worker is plain JS in a string because the server hands it out as is: no bundler step,
|
|
2
|
+
// no workbox, nothing loaded from outside the app's own origin.
|
|
3
|
+
// CacheStorage is shared by the whole origin, so each base path gets its own prefix to keep one
|
|
4
|
+
// app's activation from deleting the precache of another app on the same origin.
|
|
5
|
+
export const PWA_CACHE_NAME_PREFIX = "kumiko-pwa-";
|
|
6
|
+
const SERVICE_WORKER_BODY = `
|
|
7
|
+
const PASS_THROUGH_PATHS = ["/api", "/sse", "/auth", "/health", "/build-info.json", SERVICE_WORKER_PATH, "/_reload"];
|
|
8
|
+
const PASS_THROUGH_PREFIXES = ["/api/", "/sse/", "/auth/"];
|
|
9
|
+
|
|
10
|
+
function isPassThrough(request, url) {
|
|
11
|
+
if (request.method !== "GET") return true;
|
|
12
|
+
if (url.origin !== self.location.origin) return true;
|
|
13
|
+
if ((request.headers.get("accept") || "").includes("text/event-stream")) return true;
|
|
14
|
+
if (PASS_THROUGH_PATHS.includes(url.pathname)) return true;
|
|
15
|
+
return PASS_THROUGH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A redirected response served to a navigation request makes the browser reject it.
|
|
19
|
+
function isCacheable(response) {
|
|
20
|
+
return (
|
|
21
|
+
response.ok &&
|
|
22
|
+
response.type === "basic" &&
|
|
23
|
+
!response.redirected &&
|
|
24
|
+
!(response.headers.get("cache-control") || "").includes("no-store")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function storeInCache(request, response) {
|
|
29
|
+
if (!isCacheable(response)) return;
|
|
30
|
+
const cache = await caches.open(CACHE_NAME);
|
|
31
|
+
await cache.put(request, response);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function precache() {
|
|
35
|
+
const cache = await caches.open(CACHE_NAME);
|
|
36
|
+
await Promise.all(
|
|
37
|
+
PRECACHE_URLS.map(async (url) => {
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(new Request(url, { cache: "reload" }));
|
|
40
|
+
if (isCacheable(response)) await cache.put(url, response);
|
|
41
|
+
} catch {
|
|
42
|
+
// One missing file must not block the install; it is cached on first use instead.
|
|
43
|
+
}
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function deleteOldCaches() {
|
|
49
|
+
const names = await caches.keys();
|
|
50
|
+
await Promise.all(
|
|
51
|
+
names
|
|
52
|
+
.filter((name) => name.startsWith(CACHE_SCOPE_PREFIX) && name !== CACHE_NAME)
|
|
53
|
+
.map((name) => caches.delete(name)),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function networkFirstNavigation(request) {
|
|
58
|
+
try {
|
|
59
|
+
return await fetch(request);
|
|
60
|
+
} catch {
|
|
61
|
+
const cache = await caches.open(CACHE_NAME);
|
|
62
|
+
return (await cache.match(SHELL_URL, { ignoreVary: true })) || Response.error();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function networkFirst(request) {
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(request);
|
|
69
|
+
await storeInCache(request, response.clone());
|
|
70
|
+
return response;
|
|
71
|
+
} catch {
|
|
72
|
+
const cache = await caches.open(CACHE_NAME);
|
|
73
|
+
return (await cache.match(request, { ignoreVary: true })) || Response.error();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function cacheFirst(request) {
|
|
78
|
+
const cache = await caches.open(CACHE_NAME);
|
|
79
|
+
const cached = await cache.match(request, { ignoreVary: true });
|
|
80
|
+
if (cached) return cached;
|
|
81
|
+
const response = await fetch(request);
|
|
82
|
+
await storeInCache(request, response.clone());
|
|
83
|
+
return response;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
self.addEventListener("install", (event) => {
|
|
87
|
+
event.waitUntil(precache().then(() => self.skipWaiting()));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
self.addEventListener("activate", (event) => {
|
|
91
|
+
event.waitUntil(deleteOldCaches().then(() => self.clients.claim()));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
self.addEventListener("fetch", (event) => {
|
|
95
|
+
const request = event.request;
|
|
96
|
+
const url = new URL(request.url);
|
|
97
|
+
if (isPassThrough(request, url)) return;
|
|
98
|
+
if (request.mode === "navigate") {
|
|
99
|
+
event.respondWith(networkFirstNavigation(request));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Hashed build output never changes under one URL; everything else must not go stale.
|
|
103
|
+
if (url.pathname.startsWith("/assets/")) {
|
|
104
|
+
event.respondWith(cacheFirst(request));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (PRECACHE_URLS.includes(url.pathname)) event.respondWith(networkFirst(request));
|
|
108
|
+
});
|
|
109
|
+
`;
|
|
110
|
+
export function buildPwaServiceWorkerScript(input) {
|
|
111
|
+
return [
|
|
112
|
+
`const CACHE_SCOPE_PREFIX = ${JSON.stringify(input.cacheScopePrefix)};`,
|
|
113
|
+
`const CACHE_NAME = ${JSON.stringify(input.cacheName)};`,
|
|
114
|
+
`const SHELL_URL = ${JSON.stringify(input.shellUrl)};`,
|
|
115
|
+
`const SERVICE_WORKER_PATH = ${JSON.stringify(input.serviceWorkerPath)};`,
|
|
116
|
+
`const PRECACHE_URLS = ${JSON.stringify(input.precacheUrls)};`,
|
|
117
|
+
SERVICE_WORKER_BODY,
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
package/dist/pwa.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type PwaIconPurpose = "any" | "maskable" | "monochrome";
|
|
2
|
+
export type PwaIcon = {
|
|
3
|
+
/** Same-origin path, e.g. `/icons/app-192.png`; the app serves the file itself (usually from `public/`). */
|
|
4
|
+
readonly src: string;
|
|
5
|
+
/** `"192x192"`, several sizes separated by spaces, or `"any"` for SVG. */
|
|
6
|
+
readonly sizes: string;
|
|
7
|
+
readonly type?: string;
|
|
8
|
+
readonly purpose?: PwaIconPurpose;
|
|
9
|
+
};
|
|
10
|
+
export type PwaOptions = {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly shortName: string;
|
|
13
|
+
readonly themeColor: string;
|
|
14
|
+
readonly backgroundColor: string;
|
|
15
|
+
readonly icons: readonly PwaIcon[];
|
|
16
|
+
/** Directory the app is served under, e.g. `/a/`; starts and ends with `/`. Default `/`. */
|
|
17
|
+
readonly basePath?: string;
|
|
18
|
+
};
|
|
19
|
+
export type PwaShellBuild = {
|
|
20
|
+
/** Same-origin URLs of the client bundles and stylesheet the shell loads. */
|
|
21
|
+
readonly appShellUrls: readonly string[];
|
|
22
|
+
/** Changes whenever the served bundles change, so a new build gets a new cache. */
|
|
23
|
+
readonly buildToken: string;
|
|
24
|
+
};
|
|
25
|
+
export type PwaShell = {
|
|
26
|
+
readonly cacheName: string;
|
|
27
|
+
readonly injectHeadTags: (html: string) => string;
|
|
28
|
+
/** undefined: not a PWA file, the request goes on to the normal routes. */
|
|
29
|
+
readonly respond: (req: Request, pathname: string) => Response | undefined;
|
|
30
|
+
};
|
|
31
|
+
export declare function buildWebManifest(options: PwaOptions): Readonly<Record<string, unknown>>;
|
|
32
|
+
export declare function createPwaShell(options: PwaOptions, build: PwaShellBuild): PwaShell;
|
package/dist/pwa.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { escapeHtmlAttr, PWA_SERVICE_WORKER_META_NAME, } from "@cosmicdrift/kumiko-renderer/headless";
|
|
3
|
+
import { buildPwaServiceWorkerScript, PWA_CACHE_NAME_PREFIX } from "./pwa-service-worker-script.js";
|
|
4
|
+
const DEFAULT_PWA_BASE_PATH = "/";
|
|
5
|
+
const MANIFEST_FILE = "manifest.webmanifest";
|
|
6
|
+
const SERVICE_WORKER_FILE = "sw.js";
|
|
7
|
+
function pwaPathsOf(options) {
|
|
8
|
+
const basePath = options.basePath ?? DEFAULT_PWA_BASE_PATH;
|
|
9
|
+
return {
|
|
10
|
+
shellUrl: basePath,
|
|
11
|
+
manifestPath: `${basePath}${MANIFEST_FILE}`,
|
|
12
|
+
serviceWorkerPath: `${basePath}${SERVICE_WORKER_FILE}`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
// "/x" only: "//host/x" and "/\host" are protocol-relative to browsers (which also strip tab and
|
|
16
|
+
// newline before parsing), and the worker precaches every icon, so a foreign URL would be fetched
|
|
17
|
+
// from the user's browser.
|
|
18
|
+
function isSameOriginPath(src) {
|
|
19
|
+
return /^\/(?![/\\])/.test(src) && !src.includes("\\") && !/[\s\p{Cc}]/u.test(src);
|
|
20
|
+
}
|
|
21
|
+
function isValidBasePath(basePath) {
|
|
22
|
+
if (!isSameOriginPath(basePath) || !basePath.endsWith("/"))
|
|
23
|
+
return false;
|
|
24
|
+
if (basePath.includes("?") || basePath.includes("#"))
|
|
25
|
+
return false;
|
|
26
|
+
const segments = basePath.slice(1, -1).split("/");
|
|
27
|
+
if (basePath === "/")
|
|
28
|
+
return true;
|
|
29
|
+
return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
30
|
+
}
|
|
31
|
+
function assertValidPwaOptions(options) {
|
|
32
|
+
const basePath = options.basePath;
|
|
33
|
+
if (basePath !== undefined && !isValidBasePath(basePath)) {
|
|
34
|
+
throw new Error(`[pwa] basePath "${basePath}" must be a same-origin path that starts and ends with "/", has no empty, "." or ".." segment and no "?" or "#".`);
|
|
35
|
+
}
|
|
36
|
+
for (const icon of options.icons) {
|
|
37
|
+
if (!isSameOriginPath(icon.src)) {
|
|
38
|
+
throw new Error(`[pwa] icon src "${icon.src}" must be a same-origin path starting with a single "/".`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function buildWebManifest(options) {
|
|
43
|
+
const { shellUrl } = pwaPathsOf(options);
|
|
44
|
+
return {
|
|
45
|
+
id: shellUrl,
|
|
46
|
+
name: options.name,
|
|
47
|
+
short_name: options.shortName,
|
|
48
|
+
start_url: shellUrl,
|
|
49
|
+
scope: shellUrl,
|
|
50
|
+
display: "standalone",
|
|
51
|
+
theme_color: options.themeColor,
|
|
52
|
+
background_color: options.backgroundColor,
|
|
53
|
+
icons: options.icons.map((icon) => ({
|
|
54
|
+
src: icon.src,
|
|
55
|
+
sizes: icon.sizes,
|
|
56
|
+
...(icon.type !== undefined && { type: icon.type }),
|
|
57
|
+
...(icon.purpose !== undefined && { purpose: icon.purpose }),
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function largestEdge(sizes) {
|
|
62
|
+
return Math.max(0, ...sizes.split(/\s+/).map((size) => Number.parseInt(size, 10) || 0));
|
|
63
|
+
}
|
|
64
|
+
// iOS ignores the manifest icons and crops maskable artwork badly, so it gets the
|
|
65
|
+
// largest plain icon.
|
|
66
|
+
function pickAppleTouchIcon(icons) {
|
|
67
|
+
const plain = icons.filter((icon) => icon.purpose === undefined || icon.purpose === "any");
|
|
68
|
+
return [...plain].sort((a, b) => largestEdge(b.sizes) - largestEdge(a.sizes))[0];
|
|
69
|
+
}
|
|
70
|
+
function buildHeadTags(options) {
|
|
71
|
+
const appleIcon = pickAppleTouchIcon(options.icons);
|
|
72
|
+
const { manifestPath, serviceWorkerPath } = pwaPathsOf(options);
|
|
73
|
+
return [
|
|
74
|
+
`<link rel="manifest" href="${escapeHtmlAttr(manifestPath)}" />`,
|
|
75
|
+
`<meta name="theme-color" content="${escapeHtmlAttr(options.themeColor)}" />`,
|
|
76
|
+
`<meta name="mobile-web-app-capable" content="yes" />`,
|
|
77
|
+
`<meta name="apple-mobile-web-app-capable" content="yes" />`,
|
|
78
|
+
`<meta name="apple-mobile-web-app-title" content="${escapeHtmlAttr(options.shortName)}" />`,
|
|
79
|
+
...(appleIcon
|
|
80
|
+
? [`<link rel="apple-touch-icon" href="${escapeHtmlAttr(appleIcon.src)}" />`]
|
|
81
|
+
: []),
|
|
82
|
+
`<meta name="${PWA_SERVICE_WORKER_META_NAME}" content="${escapeHtmlAttr(serviceWorkerPath)}" />`,
|
|
83
|
+
].join("\n ");
|
|
84
|
+
}
|
|
85
|
+
function computeCacheVersion(options, build) {
|
|
86
|
+
return createHash("sha256").update(JSON.stringify({ options, build })).digest("hex").slice(0, 16);
|
|
87
|
+
}
|
|
88
|
+
function fileResponse(req, body, contentType) {
|
|
89
|
+
return new Response(req.method === "HEAD" ? null : body, {
|
|
90
|
+
headers: { "content-type": contentType, "cache-control": "no-cache" },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export function createPwaShell(options, build) {
|
|
94
|
+
assertValidPwaOptions(options);
|
|
95
|
+
const paths = pwaPathsOf(options);
|
|
96
|
+
const cacheScopePrefix = `${PWA_CACHE_NAME_PREFIX}${createHash("sha256").update(paths.shellUrl).digest("hex").slice(0, 8)}-`;
|
|
97
|
+
const cacheName = `${cacheScopePrefix}${computeCacheVersion(options, build)}`;
|
|
98
|
+
const precacheUrls = [
|
|
99
|
+
...new Set([
|
|
100
|
+
paths.shellUrl,
|
|
101
|
+
paths.manifestPath,
|
|
102
|
+
...options.icons.map((icon) => icon.src),
|
|
103
|
+
...build.appShellUrls,
|
|
104
|
+
]),
|
|
105
|
+
];
|
|
106
|
+
const manifestBody = JSON.stringify(buildWebManifest(options));
|
|
107
|
+
const serviceWorkerBody = buildPwaServiceWorkerScript({
|
|
108
|
+
cacheName,
|
|
109
|
+
cacheScopePrefix,
|
|
110
|
+
precacheUrls,
|
|
111
|
+
shellUrl: paths.shellUrl,
|
|
112
|
+
serviceWorkerPath: paths.serviceWorkerPath,
|
|
113
|
+
});
|
|
114
|
+
const headTagsHtml = buildHeadTags(options);
|
|
115
|
+
return {
|
|
116
|
+
cacheName,
|
|
117
|
+
injectHeadTags: (html) =>
|
|
118
|
+
// A replacer function, so a "$&" in an app name is not read as a replacement pattern.
|
|
119
|
+
html.includes("</head>")
|
|
120
|
+
? html.replace("</head>", () => ` ${headTagsHtml}\n </head>`)
|
|
121
|
+
: html,
|
|
122
|
+
respond: (req, pathname) => {
|
|
123
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
124
|
+
return undefined;
|
|
125
|
+
if (pathname === paths.manifestPath) {
|
|
126
|
+
return fileResponse(req, manifestBody, "application/manifest+json");
|
|
127
|
+
}
|
|
128
|
+
if (pathname === paths.serviceWorkerPath) {
|
|
129
|
+
return fileResponse(req, serviceWorkerBody, "text/javascript; charset=utf-8");
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -4,7 +4,7 @@ import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-f
|
|
|
4
4
|
import { bindMfaRevokeAllOtherSessionsFromFeature, bindRevokeAllPatTokensFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
5
5
|
import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
6
6
|
import { buildEnvConfigOverrides, createConfigAccessorFactory, createConfigResolver, validateAppOverrides, } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
7
|
-
import { collectChannels, createDeliveryNotifyFactory, createDeliveryService, DELIVERY_FEATURE, } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
7
|
+
import { collectChannels, createDeliveryAttemptLogFactory, createDeliveryNotifyFactory, createDeliveryService, DELIVERY_FEATURE, } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
8
8
|
import { bindPatAutoRevokeOnPasswordChangeFromFeature, revokeAllPatTokensForUser, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
9
9
|
import { createSecretsContext, SECRETS_FEATURE_NAME, } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
10
10
|
import { bindAutoRevokeFromFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
@@ -50,7 +50,9 @@ export function addConfigAccessorFactory(resolved, registry) {
|
|
|
50
50
|
// the calling context's job runner (3rd factory arg); without one they send
|
|
51
51
|
// inline, with `secrets` for chat-channel credentials. `to: { tenant }` resolves
|
|
52
52
|
// through tenantUserIdsQuery, which the caller sets when the tenant feature is mounted.
|
|
53
|
-
|
|
53
|
+
// The attempt log factory shares the service, so a handler sending through a channel itself
|
|
54
|
+
// logs exactly like ctx.notify does.
|
|
55
|
+
function buildDeliveryContextFactories(opts) {
|
|
54
56
|
if (!opts.addressCipher && opts.deliverQueuedInline !== true) {
|
|
55
57
|
createFallbackLogger("delivery", opts.log).warn("No master key configured: queued delivery jobs carry recipient addresses in plaintext " +
|
|
56
58
|
"(Redis, job runs). Set KUMIKO_SECRETS_MASTER_KEY_V1 (and KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION) " +
|
|
@@ -80,9 +82,12 @@ function buildDeliveryNotifyFactory(opts) {
|
|
|
80
82
|
},
|
|
81
83
|
}),
|
|
82
84
|
});
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
return {
|
|
86
|
+
_notifyFactory: createDeliveryNotifyFactory(deliveryService, {
|
|
87
|
+
deliverQueuedInline: opts.deliverQueuedInline === true,
|
|
88
|
+
}),
|
|
89
|
+
_deliveryAttemptLogFactory: createDeliveryAttemptLogFactory(deliveryService),
|
|
90
|
+
};
|
|
86
91
|
}
|
|
87
92
|
function isLogger(value) {
|
|
88
93
|
if (typeof value !== "object" || value === null)
|
|
@@ -149,7 +154,7 @@ function bootDeliveryContext(opts, parts) {
|
|
|
149
154
|
const hasTenantFeature = opts.features.some((f) => f.name === TENANT_FEATURE);
|
|
150
155
|
const { crypto, secrets, escapeHatchAuditSink } = parts;
|
|
151
156
|
return {
|
|
152
|
-
|
|
157
|
+
...buildDeliveryContextFactories({
|
|
153
158
|
db: opts.db,
|
|
154
159
|
registry: opts.registry,
|
|
155
160
|
...(secrets && { secrets }),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
3
4
|
import { createRedisCacheSyncBus, type RedisNamespace } from "@cosmicdrift/kumiko-framework/redis";
|
|
4
5
|
import { Redis } from "ioredis";
|
|
@@ -15,4 +16,4 @@ export type ProdConnections = {
|
|
|
15
16
|
readonly cacheSync: ReturnType<typeof createRedisCacheSyncBus>;
|
|
16
17
|
};
|
|
17
18
|
export declare function connectProdDependencies(config: ProdBootConfig, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup, databaseWait?: DatabaseWaitClock): Promise<ProdConnections>;
|
|
18
|
-
export declare function assertProdSchemaCurrent(db: DbConnection, migrations: RunProdAppOptions["migrations"], log: Logger): Promise<void>;
|
|
19
|
+
export declare function assertProdSchemaCurrent(db: DbConnection, migrations: RunProdAppOptions["migrations"], log: Logger, bootedFeatures: readonly FeatureDefinition[]): Promise<void>;
|
|
@@ -49,12 +49,12 @@ export async function connectProdDependencies(config, log, registerAbortCleanup,
|
|
|
49
49
|
// kumiko/migrations/.snapshot.json exists. Drift aborts boot rather than being auto-healed, avoiding
|
|
50
50
|
// races between container replicas. `migrations: false` opts out for custom
|
|
51
51
|
// schema setups.
|
|
52
|
-
export async function assertProdSchemaCurrent(db, migrations, log) {
|
|
52
|
+
export async function assertProdSchemaCurrent(db, migrations, log, bootedFeatures) {
|
|
53
53
|
if (migrations !== false) {
|
|
54
54
|
const migrationsDir = migrations?.dir ?? "./kumiko/migrations";
|
|
55
55
|
log.info(`checking schema drift (${migrationsDir})`);
|
|
56
56
|
try {
|
|
57
|
-
await assertKumikoSchemaCurrent(db, migrationsDir);
|
|
57
|
+
await assertKumikoSchemaCurrent(db, migrationsDir, bootedFeatures);
|
|
58
58
|
}
|
|
59
59
|
catch (err) {
|
|
60
60
|
if (err instanceof SchemaDriftError) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { assertNoActiveRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { flushEscapeHatchAudits } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
3
|
-
import { installShutdownSignals, resolveDrainTimeoutMs } from "./boot-options/process-signals.js";
|
|
3
|
+
import { installShutdownSignals, jobDrainBudgetMs, resolveDrainTimeoutMs, } from "./boot-options/process-signals.js";
|
|
4
4
|
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
|
|
5
5
|
import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
|
|
6
6
|
import { uninstallProcessLogger } from "./process-logger.js";
|
|
7
|
+
import { loadProdPwaShell } from "./run-prod-app-pwa.js";
|
|
7
8
|
import { buildStaticFallback, resolveCspScriptHashes, resolveHostDispatchScriptHashes, } from "./run-prod-app-static-files.js";
|
|
8
9
|
import { withSecurityHeaders } from "./security-headers.js";
|
|
9
10
|
import { stripNoRouteMatchHeader } from "./try-hono-first.js";
|
|
@@ -30,10 +31,11 @@ export async function buildProdFetchHandler(options, config, entrypoint) {
|
|
|
30
31
|
const hostDispatchScriptHashes = options.hostDispatch !== undefined && options.staticDir !== undefined
|
|
31
32
|
? await resolveHostDispatchScriptHashes(options.staticDir, options.securityHeaders, cspScriptHashes)
|
|
32
33
|
: [];
|
|
34
|
+
const pwaShell = await loadProdPwaShell(options.pwa, options.staticDir);
|
|
33
35
|
const fetchHandler = withSecurityHeaders(config.bootOptionWiring.wrapFetch(options.staticDir
|
|
34
36
|
? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
|
|
35
37
|
? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
|
|
36
|
-
: undefined, entrypoint.dispatcher, config.trustedProxyHops ?? 0, hostDispatchScriptHashes)
|
|
38
|
+
: undefined, entrypoint.dispatcher, config.trustedProxyHops ?? 0, hostDispatchScriptHashes, pwaShell)
|
|
37
39
|
: // No staticDir (split-deploy / API-only container) → app.fetch's
|
|
38
40
|
// response goes straight to the client, bypassing buildStaticFallback
|
|
39
41
|
// (and with it tryHonoFirst) entirely. Must strip the router-miss
|
|
@@ -51,8 +53,12 @@ export function createProdAppHandle(input) {
|
|
|
51
53
|
const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
|
|
52
54
|
const explicitDrainTimeoutMs = options.signals === false ? undefined : options.signals?.drainTimeoutMs;
|
|
53
55
|
const drainTimeoutMs = resolveDrainTimeoutMs(explicitDrainTimeoutMs, config.shutdownDrainTimeoutMs);
|
|
54
|
-
//
|
|
55
|
-
const jobDrainTimeoutMs =
|
|
56
|
+
// With signals off and no configured budget nothing bounds stop(), so the lifecycle keeps its own default.
|
|
57
|
+
const jobDrainTimeoutMs = options.signals === false &&
|
|
58
|
+
explicitDrainTimeoutMs === undefined &&
|
|
59
|
+
config.shutdownDrainTimeoutMs === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: jobDrainBudgetMs(drainTimeoutMs);
|
|
56
62
|
let uninstallShutdownSignals;
|
|
57
63
|
let uninstallFatalHandlers;
|
|
58
64
|
const handle = {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
|
+
import { createPwaShell } from "./pwa.js";
|
|
4
|
+
import { readStaticFile } from "./run-prod-app-static-files.js";
|
|
5
|
+
// Both files are written by kumiko build next to index.html: manifest.json maps logical
|
|
6
|
+
// names to the hashed asset URLs, build-info.json carries the build id.
|
|
7
|
+
const BUILD_ASSET_MANIFEST_FILE = "manifest.json";
|
|
8
|
+
const BUILD_INFO_FILE = "build-info.json";
|
|
9
|
+
async function readJsonFile(path) {
|
|
10
|
+
const file = await readStaticFile(path);
|
|
11
|
+
if (!file)
|
|
12
|
+
return undefined;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(new TextDecoder().decode(file.bytes));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function hashedAssetUrls(manifest) {
|
|
21
|
+
if (!isPlainObject(manifest))
|
|
22
|
+
return [];
|
|
23
|
+
return Object.values(manifest).filter((url) => typeof url === "string" && url.startsWith("/"));
|
|
24
|
+
}
|
|
25
|
+
function buildIdOf(buildInfo) {
|
|
26
|
+
return isPlainObject(buildInfo) && typeof buildInfo["id"] === "string" ? buildInfo["id"] : "";
|
|
27
|
+
}
|
|
28
|
+
export async function loadProdPwaShell(pwa, staticDir) {
|
|
29
|
+
if (pwa === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (staticDir === undefined) {
|
|
32
|
+
throw new Error("[runProdApp] `pwa` needs `staticDir`: the HTML shell is served from there.");
|
|
33
|
+
}
|
|
34
|
+
const [assetManifest, buildInfo] = await Promise.all([
|
|
35
|
+
readJsonFile(join(staticDir, BUILD_ASSET_MANIFEST_FILE)),
|
|
36
|
+
readJsonFile(join(staticDir, BUILD_INFO_FILE)),
|
|
37
|
+
]);
|
|
38
|
+
const appShellUrls = hashedAssetUrls(assetManifest);
|
|
39
|
+
// Without build-info.json the hashed URLs still change with every build.
|
|
40
|
+
return createPwaShell(pwa, { appShellUrls, buildToken: buildIdOf(buildInfo) });
|
|
41
|
+
}
|
|
@@ -50,7 +50,6 @@ async function runSeedMigrations(run, seedsDir, seedDispatcher) {
|
|
|
50
50
|
seedsDir,
|
|
51
51
|
appliedBy: "boot",
|
|
52
52
|
registry: run.composed.registry, // The dry-run validator catches handler QN typos before writing.
|
|
53
|
-
// @wrapper-known semantic-alias
|
|
54
53
|
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
|
|
55
54
|
});
|
|
56
55
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CachePolicy } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import type { PwaShell } from "./pwa.ts";
|
|
2
3
|
import { type QueryDispatcher } from "./request-bound-system-query.ts";
|
|
3
4
|
import type { HostDispatchFn, PageHeadResolver } from "./run-prod-app.ts";
|
|
4
5
|
import type { SecurityHeadersOption } from "./security-headers.ts";
|
|
@@ -34,5 +35,5 @@ hostDispatchDispatcher?: QueryDispatcher,
|
|
|
34
35
|
* 1:1 with runProdApp's effective value. */
|
|
35
36
|
trustedProxyHops?: number,
|
|
36
37
|
/** Build inline-script hashes, passed through to hostDispatch's deps. */
|
|
37
|
-
hostDispatchScriptHashes?: readonly string[]): (req: Request, socketAddress?: string) => Promise<Response>;
|
|
38
|
+
hostDispatchScriptHashes?: readonly string[], pwa?: PwaShell): (req: Request, socketAddress?: string) => Promise<Response>;
|
|
38
39
|
export declare function staticCachePolicy(pathname: string): CachePolicy;
|
|
@@ -154,7 +154,7 @@ hostDispatchDispatcher,
|
|
|
154
154
|
* 1:1 with runProdApp's effective value. */
|
|
155
155
|
trustedProxyHops = 0,
|
|
156
156
|
/** Build inline-script hashes, passed through to hostDispatch's deps. */
|
|
157
|
-
hostDispatchScriptHashes = []) {
|
|
157
|
+
hostDispatchScriptHashes = [], pwa) {
|
|
158
158
|
const indexHtml = `${staticDir}/index.html`;
|
|
159
159
|
const clientIpResolver = createClientIpResolver(trustedProxyHops, "buildStaticFallback");
|
|
160
160
|
// Reads an HTML file from disk. No schema injection — createKumikoApp
|
|
@@ -164,6 +164,11 @@ hostDispatchScriptHashes = []) {
|
|
|
164
164
|
const file = await readStaticFile(path);
|
|
165
165
|
if (!file)
|
|
166
166
|
return null;
|
|
167
|
+
if (pwa) {
|
|
168
|
+
// No mtime: the injected tags come from the boot options, not from the file.
|
|
169
|
+
const encoded = new TextEncoder().encode(pwa.injectHeadTags(new TextDecoder().decode(file.bytes)));
|
|
170
|
+
return { bytes: encoded.buffer, mime: file.mime, etag: computeStrongEtag(encoded) };
|
|
171
|
+
}
|
|
167
172
|
return {
|
|
168
173
|
bytes: new Uint8Array(file.bytes).buffer,
|
|
169
174
|
mime: file.mime,
|
|
@@ -289,6 +294,9 @@ hostDispatchScriptHashes = []) {
|
|
|
289
294
|
if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
|
|
290
295
|
return stripNoRouteMatchHeader(await apiHandler(req, socketAddress));
|
|
291
296
|
}
|
|
297
|
+
const pwaFile = pwa?.respond(req, url.pathname);
|
|
298
|
+
if (pwaFile)
|
|
299
|
+
return pwaFile;
|
|
292
300
|
// Try Hono before disk lookup so extraRoutes (for example /feed.xml and
|
|
293
301
|
// /sitemap.xml) and r.httpRoute features (for example /legal/*) are handled
|
|
294
302
|
// before unknown paths fall through to index.html. Keep this consistent with
|
package/dist/run-prod-app.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { MailBootOption } from "./boot-options/mail-option.ts";
|
|
|
23
23
|
import type { SignalsOption } from "./boot-options/process-signals.ts";
|
|
24
24
|
import type { ProdSeedsConfig } from "./boot-options/seed-shortcuts.ts";
|
|
25
25
|
import type { SystemWireDeps } from "./extra-routes-deps.ts";
|
|
26
|
+
import type { PwaOptions } from "./pwa.ts";
|
|
26
27
|
import type { RateLimitOption } from "./rate-limit-defaults.ts";
|
|
27
28
|
import type { UnsubscribeOption } from "./resolve-unsubscribe-wiring.ts";
|
|
28
29
|
import type { ConfigOverridesInput } from "./run-prod-app-boot-context.ts";
|
|
@@ -331,6 +332,11 @@ export type RunProdAppOptions = {
|
|
|
331
332
|
* anonymous role (not system) — the resolver is reachable by any
|
|
332
333
|
* public visitor, same access gate as a real anonymous request. */
|
|
333
334
|
readonly resolvePageHead?: PageHeadResolver;
|
|
335
|
+
/** Installable web app: serves `/manifest.webmanifest` and `/sw.js`, adds the manifest,
|
|
336
|
+
* theme-color and apple-touch tags to every HTML shell of `staticDir`, and the web client
|
|
337
|
+
* then registers the service worker and offers a one-time install hint. Requires
|
|
338
|
+
* `staticDir`. Pass the same value to `runDevApp`. Unset: none of this happens. */
|
|
339
|
+
readonly pwa?: PwaOptions;
|
|
334
340
|
/** Path to kumiko/migrations for the boot gate. Default "./kumiko/migrations"
|
|
335
341
|
* relative to the process cwd. Boot throws SchemaDriftError on pending
|
|
336
342
|
* migrations or missing tables. `false` disables the gate for setups that
|
package/dist/run-prod-app.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// - Hard schema-drift gate: checks kumiko/migrations against
|
|
8
8
|
// _kumiko_migrations and verifies every expected table with tableExists.
|
|
9
9
|
// Boot never runs CREATE TABLE automatically; migrations are applied by
|
|
10
|
-
// CI (`bun kumiko schema apply`) and boot only validates them. This avoids
|
|
10
|
+
// CI (`bun /app/kumiko.js schema apply`) and boot only validates them. This avoids
|
|
11
11
|
// races in multi-replica deployments and keeps schema state reviewable.
|
|
12
12
|
// - Idempotent seeds run only when the database is empty, using an
|
|
13
13
|
// `isDbEmpty` probe per seed. Reboots after the first seed are no-ops.
|
|
@@ -132,7 +132,7 @@ async function bootProdApp(options, registerAbortCleanup) {
|
|
|
132
132
|
registry,
|
|
133
133
|
cacheSync: connections.cacheSync,
|
|
134
134
|
});
|
|
135
|
-
await assertProdSchemaCurrent(connections.db, options.migrations, log);
|
|
135
|
+
await assertProdSchemaCurrent(connections.db, options.migrations, log, features);
|
|
136
136
|
await assertRowLevelSecurityBootState(connections.db, config.rowLevelSecurity, knownTablesOf(features));
|
|
137
137
|
warnIfRowLevelSecurityOff(config.rowLevelSecurity, log);
|
|
138
138
|
const requestContext = await buildProdRequestContext(options, config, composed, connections);
|
package/dist/run-worker-app.js
CHANGED
|
@@ -23,6 +23,7 @@ import { runEnvGate } from "./boot/env-gate.js";
|
|
|
23
23
|
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
24
24
|
import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
|
|
25
25
|
import { startSearchReindexOnBoot } from "./boot/search-reindex-on-boot.js";
|
|
26
|
+
import { withEnvDefaults } from "./boot/shared-boot-steps.js";
|
|
26
27
|
import { runStreamTenantBackfillOnBoot, } from "./boot/stream-tenant-backfill-on-boot.js";
|
|
27
28
|
import { bootWorkerProcess, resolveWorkerEnvSource, } from "./boot/worker-boot-core.js";
|
|
28
29
|
import { startWorkerMetricsServer, } from "./boot/worker-metrics-server.js";
|
|
@@ -77,9 +78,7 @@ export async function runWorkerApp(options) {
|
|
|
77
78
|
log.info("booting Kumiko worker");
|
|
78
79
|
assertWorkerStackRequirements(options);
|
|
79
80
|
const wiring = await resolveBootOptionWiring(options, resolvedEnvSource);
|
|
80
|
-
const envSource =
|
|
81
|
-
? { ...wiring.envDefaults, ...resolvedEnvSource }
|
|
82
|
-
: resolvedEnvSource;
|
|
81
|
+
const envSource = withEnvDefaults(wiring.envDefaults, resolvedEnvSource);
|
|
83
82
|
let boot;
|
|
84
83
|
try {
|
|
85
84
|
boot = await bootWorkerProcess({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.414.0",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
"types": "./dist/try-hono-first.d.ts",
|
|
83
83
|
"default": "./dist/try-hono-first.js"
|
|
84
84
|
},
|
|
85
|
+
"./pwa": {
|
|
86
|
+
"types": "./dist/pwa.d.ts",
|
|
87
|
+
"default": "./dist/pwa.js"
|
|
88
|
+
},
|
|
85
89
|
"./request-bound-system-query": {
|
|
86
90
|
"types": "./dist/request-bound-system-query.d.ts",
|
|
87
91
|
"default": "./dist/request-bound-system-query.js"
|
|
@@ -96,11 +100,11 @@
|
|
|
96
100
|
}
|
|
97
101
|
},
|
|
98
102
|
"dependencies": {
|
|
99
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
103
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
103
|
+
"@cosmicdrift/kumiko-bundled-features": "0.414.0",
|
|
104
|
+
"@cosmicdrift/kumiko-framework": "0.414.0",
|
|
105
|
+
"@cosmicdrift/kumiko-renderer": "0.414.0",
|
|
106
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.414.0",
|
|
107
|
+
"@cosmicdrift/kumiko-types": "0.414.0",
|
|
104
108
|
"ioredis": "^5.11.1",
|
|
105
109
|
"temporal-polyfill": "^0.3.2",
|
|
106
110
|
"zod": "^4.6.7"
|
|
@@ -109,7 +113,7 @@
|
|
|
109
113
|
"bun-types": "^1.4.4"
|
|
110
114
|
},
|
|
111
115
|
"devDependencies": {
|
|
112
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
116
|
+
"@cosmicdrift/kumiko-testing": "0.414.0",
|
|
113
117
|
"bun-types": "^1.4.4"
|
|
114
118
|
},
|
|
115
119
|
"publishConfig": {
|
|
@@ -180,6 +184,10 @@
|
|
|
180
184
|
"types": "./dist/try-hono-first.d.ts",
|
|
181
185
|
"default": "./dist/try-hono-first.js"
|
|
182
186
|
},
|
|
187
|
+
"./pwa": {
|
|
188
|
+
"types": "./dist/pwa.d.ts",
|
|
189
|
+
"default": "./dist/pwa.js"
|
|
190
|
+
},
|
|
183
191
|
"./request-bound-system-query": {
|
|
184
192
|
"types": "./dist/request-bound-system-query.d.ts",
|
|
185
193
|
"default": "./dist/request-bound-system-query.js"
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.414.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The stripe boot option passes clientConfig",
|
|
6
|
+
"detail": "`billing: { stripe: { clientConfig } }` is handed to the subscription-stripe feature, so an app can set the Stripe client configuration (API version, retries, host) from the boot options."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.414.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "provider-wide billing webhookPath, shared worker boot steps, no leaked SSE broker",
|
|
12
|
+
"detail": "`billing.webhookPath` replaces `billing.stripe.webhookPath` (deprecated, still read as a fallback) and the error message names the new key. `runWorkerApp` applies env defaults through `withEnvDefaults` and `bootWorkerProcess` validates through the shared `validateBootAndWarnTz` step. If `createWorkerEntrypoint` throws, the Redis SSE broker opened before it is closed instead of keeping the process alive. The stream tenant backfill decides searchability with the shared `isEntitySearchable`."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.414.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "job drain leaves a tail reserve of the shutdown budget",
|
|
18
|
+
"detail": "The SIGTERM timer and the job drain in `stop()` used the same budget, so a job that ran the whole time ended the process with exit code 1 before the HTTP shutdown, the escape-hatch audit flush and the connection close ran. The job drain now gets the budget minus a 5 s reserve (at least 1 s). `KUMIKO_SHUTDOWN_DRAIN_TIMEOUT_MS` is the budget for both."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.411.0",
|
|
4
22
|
"type": "improvement",
|