@cosmicdrift/kumiko-server-runtime 0.413.1 → 0.415.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.
@@ -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 of the entity or the document format change. */
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 of the entity or the document format change. */
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
- const extensionCount = registry.getSearchPayloadExtensions(entityName).length;
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(",")}|${extensionCount}`)
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 validateComposedFeatures(features, bootOptions) {
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.getSearchableFields(entity).length > 0 ||
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
- "ERR_POSTGRES_CONNECTION_CLOSED",
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, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
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,18 +14,18 @@ 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";
21
20
  import { composeFeatures } from "../compose-features.js";
22
21
  import { assertPiiBootInvariants } from "../pii-boot-gate.js";
23
22
  import { readEnv, requireEnv } from "../require-env.js";
23
+ import { resolveUnsubscribeWiring } from "../resolve-unsubscribe-wiring.js";
24
24
  import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, } from "../run-prod-app-boot-context.js";
25
25
  import { resolveBootCrypto } from "./boot-crypto.js";
26
26
  import { jobRunLoggerCallbacks } from "./job-run-logger.js";
27
27
  import { INSTANCE_ID_ENV } from "./record-app-started-on-boot.js";
28
- import { buildEffectiveFeaturesResolver, openDatabasePools, waitForKmsHealthy, } from "./shared-boot-steps.js";
28
+ import { buildEffectiveFeaturesResolver, openDatabasePools, validateBootAndWarnTz, waitForKmsHealthy, } from "./shared-boot-steps.js";
29
29
  import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase } from "./wait-for-database.js";
30
30
  import { warnIfRowLevelSecurityOff } from "./warn-row-level-security-off.js";
31
31
  import { assertWorkerMetricsOptions } from "./worker-metrics-server.js";
@@ -43,12 +43,12 @@ export async function resolveWorkerEnvSource(options, processName) {
43
43
  })
44
44
  : rawEnvSource;
45
45
  }
46
- async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
46
+ async function assertWorkerSchemaCurrent(db, migrationsDir, processName, bootedFeatures) {
47
47
  const dir = migrationsDir ?? "./kumiko/migrations";
48
48
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint
49
49
  console.log(`[${processName}] checking schema drift (${dir})…`);
50
50
  try {
51
- await assertKumikoSchemaCurrent(db, dir);
51
+ await assertKumikoSchemaCurrent(db, dir, bootedFeatures);
52
52
  }
53
53
  catch (err) {
54
54
  if (err instanceof SchemaDriftError) {
@@ -58,6 +58,16 @@ async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
58
58
  throw err;
59
59
  }
60
60
  }
61
+ // The broker is opened before the entrypoint; a throwing entrypoint would otherwise leak its connection.
62
+ async function createWorkerEntrypointOrCloseBroker(ownedBroker, args) {
63
+ try {
64
+ return createWorkerEntrypoint(args);
65
+ }
66
+ catch (error) {
67
+ await ownedBroker?.close();
68
+ throw error;
69
+ }
70
+ }
61
71
  export async function bootWorkerProcess(options, envSource, profile) {
62
72
  const { processName } = profile;
63
73
  // Polyfill before anything else: without it every job fails with
@@ -79,12 +89,11 @@ export async function bootWorkerProcess(options, envSource, profile) {
79
89
  ...(options.bundledLists && { bundledLists: options.bundledLists }),
80
90
  });
81
91
  const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
82
- validateBoot(features, {
92
+ validateBootAndWarnTz(features, {
83
93
  env: envSource,
84
94
  ...(bootCrypto.entityFieldCipher && { entityFieldCipher: bootCrypto.entityFieldCipher }),
85
95
  ...options.validateBootOptions,
86
96
  });
87
- warnIfNonUtcServerTimeZone();
88
97
  assertWorkerMetricsOptions(options.metrics, options.observability, processName);
89
98
  assertPiiBootInvariants(features, {
90
99
  kms: options.kms,
@@ -142,7 +151,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
142
151
  });
143
152
  if (options.migrations !== false) {
144
153
  try {
145
- await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName);
154
+ await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName, features);
146
155
  }
147
156
  catch (err) {
148
157
  await closeDb();
@@ -176,6 +185,13 @@ export async function bootWorkerProcess(options, envSource, profile) {
176
185
  configureBlindIndexKey(options.blindIndexKey);
177
186
  const bootLogger = loggerFromExtraContext(resolvedExtraContext);
178
187
  warnIfRowLevelSecurityOff(rowLevelSecurity, bootLogger ?? createFallbackLogger(processName));
188
+ const { unsubscribeLinks } = resolveUnsubscribeWiring({
189
+ unsubscribe: options.unsubscribe,
190
+ publicOrigin: undefined,
191
+ mailBaseUrl: undefined,
192
+ envSource,
193
+ warn: (message) => (bootLogger ?? createFallbackLogger(processName)).warn(message),
194
+ });
179
195
  const autoExtraContext = buildBootExtraContext({
180
196
  db,
181
197
  features,
@@ -188,11 +204,12 @@ export async function bootWorkerProcess(options, envSource, profile) {
188
204
  ...(options.kms && { kms: options.kms }),
189
205
  ...(profile.deliverQueuedInline === true && { deliverQueuedInline: true }),
190
206
  ...(bootLogger && { log: bootLogger }),
207
+ ...(unsubscribeLinks && { unsubscribeLinks }),
191
208
  });
192
209
  const extraContext = addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry);
193
210
  const jobLogger = jobRunLoggerCallbacks(registry, db);
194
211
  const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
195
- const entrypoint = createWorkerEntrypoint({
212
+ const entrypoint = await createWorkerEntrypointOrCloseBroker(ownedRedisSseBroker, {
196
213
  sseBroker,
197
214
  registry,
198
215
  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
- * `PADDLE_TENANT_BINDING_KEY` / `PADDLE_CLIENT_TOKEN`) are only the bridge fallback; the keys
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
- /** Default `SUBSCRIPTION_WEBHOOK_PATH`; must contain `:providerName`. Applies to every provider. */
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.stripe.webhookPath "${webhookPath}" must contain ${PROVIDER_NAME_PARAM}.`);
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,10 @@
1
+ import { PWA_CACHE_NAME_PREFIX } from "@cosmicdrift/kumiko-renderer/headless";
2
+ export { PWA_CACHE_NAME_PREFIX };
3
+ export type PwaServiceWorkerScriptInput = {
4
+ readonly cacheName: string;
5
+ readonly cacheScopePrefix: string;
6
+ readonly precacheUrls: readonly string[];
7
+ readonly shellUrl: string;
8
+ readonly serviceWorkerPath: string;
9
+ };
10
+ export declare function buildPwaServiceWorkerScript(input: PwaServiceWorkerScriptInput): string;
@@ -0,0 +1,127 @@
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
+ import { PWA_CACHE_NAME_PREFIX } from "@cosmicdrift/kumiko-renderer/headless";
4
+ export { PWA_CACHE_NAME_PREFIX };
5
+ // Cache names before scoping were `kumiko-pwa-<16hex>`; kept only so an update drops those precaches once.
6
+ // The pattern also matches the precache of another app on the same origin that still runs an old
7
+ // framework version; accepted, because that app only has to precache again.
8
+ const UNSCOPED_CACHE_NAME_PATTERN = /^kumiko-pwa-[0-9a-f]{16}$/;
9
+ const SERVICE_WORKER_BODY = `
10
+ const PASS_THROUGH_PATHS = ["/api", "/sse", "/auth", "/health", "/build-info.json", SERVICE_WORKER_PATH, "/_reload"];
11
+ const PASS_THROUGH_PREFIXES = ["/api/", "/sse/", "/auth/"];
12
+
13
+ function isPassThrough(request, url) {
14
+ if (request.method !== "GET") return true;
15
+ if (url.origin !== self.location.origin) return true;
16
+ if ((request.headers.get("accept") || "").includes("text/event-stream")) return true;
17
+ if (PASS_THROUGH_PATHS.includes(url.pathname)) return true;
18
+ return PASS_THROUGH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix));
19
+ }
20
+
21
+ // A redirected response served to a navigation request makes the browser reject it.
22
+ function isCacheable(response) {
23
+ return (
24
+ response.ok &&
25
+ response.type === "basic" &&
26
+ !response.redirected &&
27
+ !(response.headers.get("cache-control") || "").includes("no-store")
28
+ );
29
+ }
30
+
31
+ async function storeInCache(request, response) {
32
+ if (!isCacheable(response)) return;
33
+ const cache = await caches.open(CACHE_NAME);
34
+ await cache.put(request, response);
35
+ }
36
+
37
+ async function precache() {
38
+ const cache = await caches.open(CACHE_NAME);
39
+ await Promise.all(
40
+ PRECACHE_URLS.map(async (url) => {
41
+ try {
42
+ const response = await fetch(new Request(url, { cache: "reload" }));
43
+ if (isCacheable(response)) await cache.put(url, response);
44
+ } catch {
45
+ // One missing file must not block the install; it is cached on first use instead.
46
+ }
47
+ }),
48
+ );
49
+ }
50
+
51
+ async function deleteOldCaches() {
52
+ const names = await caches.keys();
53
+ await Promise.all(
54
+ names
55
+ .filter(
56
+ (name) =>
57
+ (name.startsWith(CACHE_SCOPE_PREFIX) || UNSCOPED_CACHE_NAME_PATTERN.test(name)) &&
58
+ name !== CACHE_NAME,
59
+ )
60
+ .map((name) => caches.delete(name)),
61
+ );
62
+ }
63
+
64
+ async function networkFirstNavigation(request) {
65
+ try {
66
+ return await fetch(request);
67
+ } catch {
68
+ const cache = await caches.open(CACHE_NAME);
69
+ return (await cache.match(SHELL_URL, { ignoreVary: true })) || Response.error();
70
+ }
71
+ }
72
+
73
+ async function networkFirst(request) {
74
+ try {
75
+ const response = await fetch(request);
76
+ await storeInCache(request, response.clone());
77
+ return response;
78
+ } catch {
79
+ const cache = await caches.open(CACHE_NAME);
80
+ return (await cache.match(request, { ignoreVary: true })) || Response.error();
81
+ }
82
+ }
83
+
84
+ async function cacheFirst(request) {
85
+ const cache = await caches.open(CACHE_NAME);
86
+ const cached = await cache.match(request, { ignoreVary: true });
87
+ if (cached) return cached;
88
+ const response = await fetch(request);
89
+ await storeInCache(request, response.clone());
90
+ return response;
91
+ }
92
+
93
+ self.addEventListener("install", (event) => {
94
+ event.waitUntil(precache().then(() => self.skipWaiting()));
95
+ });
96
+
97
+ self.addEventListener("activate", (event) => {
98
+ event.waitUntil(deleteOldCaches().then(() => self.clients.claim()));
99
+ });
100
+
101
+ self.addEventListener("fetch", (event) => {
102
+ const request = event.request;
103
+ const url = new URL(request.url);
104
+ if (isPassThrough(request, url)) return;
105
+ if (request.mode === "navigate") {
106
+ event.respondWith(networkFirstNavigation(request));
107
+ return;
108
+ }
109
+ // Hashed build output never changes under one URL; everything else must not go stale.
110
+ if (url.pathname.startsWith("/assets/")) {
111
+ event.respondWith(cacheFirst(request));
112
+ return;
113
+ }
114
+ if (PRECACHE_URLS.includes(url.pathname)) event.respondWith(networkFirst(request));
115
+ });
116
+ `;
117
+ export function buildPwaServiceWorkerScript(input) {
118
+ return [
119
+ `const CACHE_SCOPE_PREFIX = ${JSON.stringify(input.cacheScopePrefix)};`,
120
+ `const CACHE_NAME = ${JSON.stringify(input.cacheName)};`,
121
+ `const UNSCOPED_CACHE_NAME_PATTERN = ${UNSCOPED_CACHE_NAME_PATTERN.toString()};`,
122
+ `const SHELL_URL = ${JSON.stringify(input.shellUrl)};`,
123
+ `const SERVICE_WORKER_PATH = ${JSON.stringify(input.serviceWorkerPath)};`,
124
+ `const PRECACHE_URLS = ${JSON.stringify(input.precacheUrls)};`,
125
+ SERVICE_WORKER_BODY,
126
+ ].join("\n");
127
+ }
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_CACHE_SCOPE_HASH_LENGTH, 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, PWA_CACHE_SCOPE_HASH_LENGTH)}-`;
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
- function buildDeliveryNotifyFactory(opts) {
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 createDeliveryNotifyFactory(deliveryService, {
84
- deliverQueuedInline: opts.deliverQueuedInline === true,
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
- _notifyFactory: buildDeliveryNotifyFactory({
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
- // Without a configured budget the lifecycle keeps its own default for programmatic stop().
55
- const jobDrainTimeoutMs = explicitDrainTimeoutMs ?? config.shutdownDrainTimeoutMs;
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,2 @@
1
+ import { type PwaOptions, type PwaShell } from "./pwa.ts";
2
+ export declare function loadProdPwaShell(pwa: PwaOptions | undefined, staticDir: string | undefined): Promise<PwaShell | undefined>;
@@ -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
@@ -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
@@ -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);
@@ -16,6 +16,7 @@ import type { FileStorageBootOption } from "./boot-options/file-storage-option.t
16
16
  import type { KmsMode } from "./boot-options/kms-mode.ts";
17
17
  import type { MailBootOption } from "./boot-options/mail-option.ts";
18
18
  import { type SystemWireDeps } from "./extra-routes-deps.ts";
19
+ import type { UnsubscribeOption } from "./resolve-unsubscribe-wiring.ts";
19
20
  import type { ConfigOverridesInput } from "./run-prod-app-boot-context.ts";
20
21
  export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
21
22
  export type WorkerDeps = {
@@ -112,6 +113,10 @@ export type RunWorkerAppOptions = {
112
113
  readonly billing?: BillingBootOption;
113
114
  /** Same as RunProdAppOptions["featureToggles"]: mounts feature-toggles and layers the operator switches over the tier resolver. */
114
115
  readonly featureToggles?: boolean;
116
+ /** Signs the unsubscribe links of `unsubscribe: true` mails that jobs in this process trigger, as
117
+ * RunProdAppOptions["unsubscribe"] does. The unsubscribe routes stay in the API process; `secret`
118
+ * must be the same there. The public URL comes from `APP_BASE_URL`. */
119
+ readonly unsubscribe?: UnsubscribeOption;
115
120
  /** Composed env schema (see RunProdAppOptions["envSchema"]). With it the worker validates its env,
116
121
  * derives the KMS slots from it (`kmsSlots` is ignored) and understands every `KUMIKO_DRY_RUN_ENV`
117
122
  * mode. Without it only `KUMIKO_DRY_RUN_ENV=boot` is available. */
@@ -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 = Object.keys(wiring.envDefaults).length > 0
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.413.1",
3
+ "version": "0.415.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.413.1",
100
- "@cosmicdrift/kumiko-framework": "0.413.1",
101
- "@cosmicdrift/kumiko-renderer": "0.413.1",
102
- "@cosmicdrift/kumiko-renderer-mail-html": "0.413.1",
103
- "@cosmicdrift/kumiko-types": "0.413.1",
103
+ "@cosmicdrift/kumiko-bundled-features": "0.415.0",
104
+ "@cosmicdrift/kumiko-framework": "0.415.0",
105
+ "@cosmicdrift/kumiko-renderer": "0.415.0",
106
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.415.0",
107
+ "@cosmicdrift/kumiko-types": "0.415.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.413.1",
116
+ "@cosmicdrift/kumiko-testing": "0.415.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,34 @@
1
1
  [
2
+ {
3
+ "version": "0.415.0",
4
+ "type": "fix",
5
+ "title": "PWA update removes the precache of the unscoped cache name format",
6
+ "detail": "Caches named kumiko-pwa-<16hex> from before the per-scope prefix were never deleted after an update. The service worker now drops them once, and keeps caches of other scopes and foreign libraries."
7
+ },
8
+ {
9
+ "version": "0.415.0",
10
+ "type": "improvement",
11
+ "title": "runWorkerApp accepts the unsubscribe option",
12
+ "detail": "Pass the same `unsubscribe: { secret }` to runWorkerApp as to runProdApp; a job in the worker (e.g. a cron digest) that sends an `unsubscribe: true` notification then gets a signed link from `APP_BASE_URL`. The unsubscribe routes stay in the API process, which must use the same secret. Before, such mails went out without a link."
13
+ },
14
+ {
15
+ "version": "0.414.0",
16
+ "type": "improvement",
17
+ "title": "The stripe boot option passes clientConfig",
18
+ "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."
19
+ },
20
+ {
21
+ "version": "0.414.0",
22
+ "type": "fix",
23
+ "title": "provider-wide billing webhookPath, shared worker boot steps, no leaked SSE broker",
24
+ "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`."
25
+ },
26
+ {
27
+ "version": "0.414.0",
28
+ "type": "fix",
29
+ "title": "job drain leaves a tail reserve of the shutdown budget",
30
+ "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."
31
+ },
2
32
  {
3
33
  "version": "0.411.0",
4
34
  "type": "improvement",