@cosmicdrift/kumiko-dev-server 0.146.4 → 0.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/compose-stacks.test.ts +22 -3
- package/src/__tests__/few-shot-corpus.test.ts +15 -10
- package/src/__tests__/merge-extra-context.test.ts +56 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +5 -0
- package/src/compose-stacks.ts +4 -3
- package/src/create-kumiko-server.ts +6 -0
- package/src/run-prod-app-boot-context.ts +234 -0
- package/src/run-prod-app-static-files.ts +273 -0
- package/src/run-prod-app.ts +27 -481
- package/src/scaffold-app-feature.ts +5 -4
- package/src/setup-test-stack-from-features.ts +1 -1
package/src/run-prod-app.ts
CHANGED
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
type AuthPaths,
|
|
32
32
|
type EmailVerificationOptions,
|
|
33
33
|
type InviteOptions,
|
|
34
|
-
makeAuthPaths,
|
|
35
34
|
type PasswordResetOptions,
|
|
36
35
|
type SignupOptions,
|
|
37
36
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
@@ -39,44 +38,20 @@ import {
|
|
|
39
38
|
type SeedAdminOptions,
|
|
40
39
|
seedAdmin,
|
|
41
40
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
42
|
-
import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
43
|
-
import {
|
|
44
|
-
buildEnvConfigOverrides,
|
|
45
|
-
createConfigAccessorFactory,
|
|
46
|
-
createConfigResolver,
|
|
47
|
-
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
48
|
-
import {
|
|
49
|
-
collectChannels,
|
|
50
|
-
createDeliveryService,
|
|
51
|
-
DELIVERY_FEATURE,
|
|
52
|
-
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
53
41
|
import {
|
|
54
42
|
createPatResolver,
|
|
55
43
|
PAT_FEATURE,
|
|
56
44
|
patRateLimitFromFeature,
|
|
57
45
|
patScopesFromFeature,
|
|
58
46
|
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
59
|
-
import {
|
|
60
|
-
createSecretsContext,
|
|
61
|
-
SECRETS_FEATURE_NAME,
|
|
62
|
-
} from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
63
|
-
import {
|
|
64
|
-
bindAutoRevokeFromFeature,
|
|
65
|
-
createSessionCallbacks,
|
|
66
|
-
SESSIONS_FEATURE,
|
|
67
|
-
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
47
|
+
import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
68
48
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
69
49
|
import {
|
|
70
50
|
resolveTenantLifecycleGate,
|
|
71
51
|
TENANT_LIFECYCLE_FEATURE,
|
|
72
52
|
} from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
73
|
-
import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
74
53
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
75
54
|
import {
|
|
76
|
-
type CachePolicy,
|
|
77
|
-
cachedResponse,
|
|
78
|
-
computeStrongEtag,
|
|
79
|
-
computeWeakEtag,
|
|
80
55
|
createInMemoryLoginRateLimiter,
|
|
81
56
|
createSseBroker,
|
|
82
57
|
type SseBroker,
|
|
@@ -89,19 +64,15 @@ import {
|
|
|
89
64
|
import {
|
|
90
65
|
configureEntityFieldEncryption,
|
|
91
66
|
createDbConnection,
|
|
92
|
-
type DbConnection,
|
|
93
67
|
type DbRunner,
|
|
94
68
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
95
69
|
import {
|
|
96
70
|
buildAppSchema,
|
|
97
|
-
type ConfigResolver,
|
|
98
71
|
collectWriteHandlerQns,
|
|
99
72
|
createRegistry,
|
|
100
73
|
type EffectiveFeaturesResolver,
|
|
101
74
|
type FeatureDefinition,
|
|
102
75
|
findTierResolverUsage,
|
|
103
|
-
type NotifyFactory,
|
|
104
|
-
type Registry,
|
|
105
76
|
type TierResolverPlugin,
|
|
106
77
|
validateAppCustomScreenWriteQns,
|
|
107
78
|
validateBoot,
|
|
@@ -127,6 +98,7 @@ import {
|
|
|
127
98
|
assertKumikoSchemaCurrent,
|
|
128
99
|
SchemaDriftError,
|
|
129
100
|
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
101
|
+
import type { ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
|
|
130
102
|
import {
|
|
131
103
|
createDispatcher,
|
|
132
104
|
createEntityCache,
|
|
@@ -137,20 +109,23 @@ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
137
109
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
138
110
|
import Redis from "ioredis";
|
|
139
111
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
140
|
-
import {
|
|
112
|
+
import { resolveBootCrypto } from "./boot/boot-crypto";
|
|
141
113
|
import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
|
|
142
|
-
import { ASSETS_DIR } from "./build-prod-bundle";
|
|
143
114
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
144
115
|
import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
|
|
145
|
-
import { injectSchema } from "./inject-schema";
|
|
146
116
|
import { assertPiiBootInvariants } from "./pii-boot-gate";
|
|
147
117
|
import {
|
|
148
|
-
|
|
118
|
+
addConfigAccessorFactory,
|
|
119
|
+
buildBootExtraContext,
|
|
120
|
+
buildProdSessionAuth,
|
|
121
|
+
resolveAuthMail,
|
|
122
|
+
} from "./run-prod-app-boot-context";
|
|
123
|
+
import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
124
|
+
import {
|
|
149
125
|
type ProdSessionsOption,
|
|
150
126
|
resolveProdSessionsConfig,
|
|
151
127
|
shouldWireProdSessions,
|
|
152
128
|
} from "./session-wiring";
|
|
153
|
-
import { tryHonoFirst } from "./try-hono-first";
|
|
154
129
|
|
|
155
130
|
/**
|
|
156
131
|
* Bun.serve-Options für Production.
|
|
@@ -164,6 +139,13 @@ import { tryHonoFirst } from "./try-hono-first";
|
|
|
164
139
|
* Spec-Test in __tests__/run-prod-app-spec.test.ts pinst die 0 gegen
|
|
165
140
|
* "looks like a leak"-Reverts.
|
|
166
141
|
*/
|
|
142
|
+
export {
|
|
143
|
+
addConfigAccessorFactory,
|
|
144
|
+
buildBootExtraContext,
|
|
145
|
+
resolveAuthMail,
|
|
146
|
+
} from "./run-prod-app-boot-context";
|
|
147
|
+
export { staticCachePolicy } from "./run-prod-app-static-files";
|
|
148
|
+
|
|
167
149
|
export function buildBunServeOptions(
|
|
168
150
|
port: number,
|
|
169
151
|
fetchHandler: (req: Request) => Response | Promise<Response>,
|
|
@@ -588,6 +570,14 @@ export type RunProdAppOptions = {
|
|
|
588
570
|
* Tests use this to feed crafted env-maps without polluting the
|
|
589
571
|
* global. */
|
|
590
572
|
readonly envSource?: Record<string, string | undefined>;
|
|
573
|
+
/** Observability-Provider (Tracer + Meter). Default: Noop (kein Overhead,
|
|
574
|
+
* kein `/metrics`-Endpoint). Setze `createPrometheusMeter()`-basierten
|
|
575
|
+
* Provider + `metrics` um `/metrics` real zu exposen (publicstatus#91). */
|
|
576
|
+
readonly observability?: ObservabilityProvider;
|
|
577
|
+
/** Aktiviert den `/metrics`-Endpoint (Open-Metrics-Format). Ohne
|
|
578
|
+
* `observability` bleibt der Meter ein Noop und die Route liefert leere
|
|
579
|
+
* Ausgabe — beides zusammen setzen. */
|
|
580
|
+
readonly metrics?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["metrics"];
|
|
591
581
|
};
|
|
592
582
|
|
|
593
583
|
export type ProdAppHandle = {
|
|
@@ -608,167 +598,6 @@ export type ProdAppHandle = {
|
|
|
608
598
|
readonly stop: () => Promise<void>;
|
|
609
599
|
};
|
|
610
600
|
|
|
611
|
-
// Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
|
|
612
|
-
export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
|
|
613
|
-
resolved: T,
|
|
614
|
-
registry: Registry,
|
|
615
|
-
): T {
|
|
616
|
-
if (!resolved.configResolver) return resolved;
|
|
617
|
-
return {
|
|
618
|
-
...resolved,
|
|
619
|
-
_configAccessorFactory: createConfigAccessorFactory(registry, resolved.configResolver),
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
|
|
624
|
-
// der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
|
|
625
|
-
// Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
|
|
626
|
-
// Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
|
|
627
|
-
// nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
|
|
628
|
-
// das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
|
|
629
|
-
// (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
|
|
630
|
-
// KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
|
|
631
|
-
// (envHasMasterKek, resolveBootCrypto) — gemeinsam mit runDevApp.
|
|
632
|
-
// Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
|
|
633
|
-
// beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
|
|
634
|
-
// ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
|
|
635
|
-
// Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
|
|
636
|
-
// passiert beim Caller (App gewinnt).
|
|
637
|
-
|
|
638
|
-
// Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
|
|
639
|
-
// in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
|
|
640
|
-
// and every notification silently skips. sseBroker optional (email/push don't
|
|
641
|
-
// need it, in-app SSE does); no jobRunner → queued channels send inline.
|
|
642
|
-
function buildDeliveryNotifyFactory(opts: {
|
|
643
|
-
readonly db: DbConnection;
|
|
644
|
-
readonly registry: Registry;
|
|
645
|
-
readonly sseBroker?: SseBroker;
|
|
646
|
-
}): NotifyFactory {
|
|
647
|
-
const deliveryService = createDeliveryService({
|
|
648
|
-
db: opts.db,
|
|
649
|
-
registry: opts.registry,
|
|
650
|
-
channels: collectChannels(opts.registry),
|
|
651
|
-
...(opts.sseBroker && { sseBroker: opts.sseBroker }),
|
|
652
|
-
});
|
|
653
|
-
return (user, tenantId) => (notificationType, options) =>
|
|
654
|
-
deliveryService.notify(notificationType, options, user, tenantId);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
export function buildBootExtraContext(opts: {
|
|
658
|
-
readonly db: DbConnection;
|
|
659
|
-
readonly features: readonly FeatureDefinition[];
|
|
660
|
-
readonly envSource: Record<string, string | undefined>;
|
|
661
|
-
readonly registry: Registry;
|
|
662
|
-
readonly hasAuth: boolean;
|
|
663
|
-
// Resolved once per boot via resolveBootCrypto — shared by secrets,
|
|
664
|
-
// config-resolver, config-set-handler and boot-seeds. Absent (tests
|
|
665
|
-
// that don't care about encryption) ⇒ resolved from envSource here.
|
|
666
|
-
readonly crypto?: BootCrypto;
|
|
667
|
-
readonly masterKey?: MasterKeyProvider;
|
|
668
|
-
readonly sseBroker?: SseBroker;
|
|
669
|
-
readonly kms?: KmsAdapter;
|
|
670
|
-
}): Record<string, unknown> {
|
|
671
|
-
const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
|
|
672
|
-
const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
|
|
673
|
-
const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
|
|
674
|
-
const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
|
|
675
|
-
return {
|
|
676
|
-
textContent: createTextContentApi(opts.db),
|
|
677
|
-
...(opts.kms && { kms: opts.kms }),
|
|
678
|
-
...(hasDeliveryFeature && {
|
|
679
|
-
_notifyFactory: buildDeliveryNotifyFactory({
|
|
680
|
-
db: opts.db,
|
|
681
|
-
registry: opts.registry,
|
|
682
|
-
...(opts.sseBroker && { sseBroker: opts.sseBroker }),
|
|
683
|
-
}),
|
|
684
|
-
}),
|
|
685
|
-
// Top-level provider so feature jobs (secrets rotate, config reencrypt)
|
|
686
|
-
// reach it via ctx — previously only test-stack wired it.
|
|
687
|
-
...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
|
|
688
|
-
// Encrypt/decrypt partner for `encrypted: true` config keys. Wired
|
|
689
|
-
// whenever a master key exists — NOT gated on the secrets feature,
|
|
690
|
-
// config encryption must work without mounting ctx.secrets.
|
|
691
|
-
...(crypto.configCipher && { configEncryption: crypto.configCipher }),
|
|
692
|
-
...(wireSecrets &&
|
|
693
|
-
crypto.masterKeyProvider && {
|
|
694
|
-
secrets: createSecretsContext({
|
|
695
|
-
db: opts.db,
|
|
696
|
-
masterKeyProvider: crypto.masterKeyProvider,
|
|
697
|
-
dekCache: crypto.dekCache,
|
|
698
|
-
}),
|
|
699
|
-
}),
|
|
700
|
-
...(opts.hasAuth && {
|
|
701
|
-
configResolver: createConfigResolver({
|
|
702
|
-
appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
|
|
703
|
-
...(crypto.configCipher && { cipher: crypto.configCipher }),
|
|
704
|
-
}),
|
|
705
|
-
}),
|
|
706
|
-
};
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// auth.mail-Convenience → normalisiert in die expliziten passwordReset/
|
|
710
|
-
// emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
|
|
711
|
-
// (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
|
|
712
|
-
// so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
|
|
713
|
-
// den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
|
|
714
|
-
// unverdrahtet (sonst lieferten die reset/verify-Routes 500).
|
|
715
|
-
/** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
|
|
716
|
-
* App-Auth-Typen (prod + dev) erfüllen das strukturell. */
|
|
717
|
-
type AuthMailNormalizable = {
|
|
718
|
-
readonly mail?: AuthMailOptions;
|
|
719
|
-
readonly passwordReset?: PasswordResetSetup;
|
|
720
|
-
readonly emailVerification?: EmailVerificationSetup;
|
|
721
|
-
readonly signup?: SignupSetup;
|
|
722
|
-
readonly invite?: InviteSetup;
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
726
|
-
auth: T,
|
|
727
|
-
hmacSecret: string,
|
|
728
|
-
envSource: Record<string, string | undefined>,
|
|
729
|
-
): T {
|
|
730
|
-
if (!auth.mail) return auth;
|
|
731
|
-
// SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
|
|
732
|
-
// blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
|
|
733
|
-
// (channel-email), nicht über diesen Transport — er ist nur der Detektor
|
|
734
|
-
// "ist Mail konfiguriert?".
|
|
735
|
-
if (
|
|
736
|
-
!createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
|
|
737
|
-
) {
|
|
738
|
-
return auth;
|
|
739
|
-
}
|
|
740
|
-
const paths = makeAuthPaths(auth.mail.paths);
|
|
741
|
-
// appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
|
|
742
|
-
const mailPresentation = {
|
|
743
|
-
...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
|
|
744
|
-
...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
|
|
745
|
-
};
|
|
746
|
-
return {
|
|
747
|
-
...auth,
|
|
748
|
-
passwordReset: auth.passwordReset ?? {
|
|
749
|
-
hmacSecret,
|
|
750
|
-
appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
|
|
751
|
-
...mailPresentation,
|
|
752
|
-
},
|
|
753
|
-
emailVerification: auth.emailVerification ?? {
|
|
754
|
-
hmacSecret,
|
|
755
|
-
appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
|
|
756
|
-
...(auth.mail.emailVerificationMode !== undefined && {
|
|
757
|
-
mode: auth.mail.emailVerificationMode,
|
|
758
|
-
}),
|
|
759
|
-
...mailPresentation,
|
|
760
|
-
},
|
|
761
|
-
signup: auth.signup ?? {
|
|
762
|
-
appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
|
|
763
|
-
...mailPresentation,
|
|
764
|
-
},
|
|
765
|
-
invite: auth.invite ?? {
|
|
766
|
-
appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
|
|
767
|
-
...mailPresentation,
|
|
768
|
-
},
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
601
|
export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
|
|
773
602
|
// 0. Env-Schema validation + dry-run modes. Runs FIRST so:
|
|
774
603
|
// - operators can introspect env-requirements without a real boot
|
|
@@ -1069,6 +898,8 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1069
898
|
...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
|
|
1070
899
|
},
|
|
1071
900
|
eventDedup,
|
|
901
|
+
...(options.observability && { observability: options.observability }),
|
|
902
|
+
...(options.metrics && { metrics: options.metrics }),
|
|
1072
903
|
...(effectiveAuth && {
|
|
1073
904
|
auth: {
|
|
1074
905
|
membershipQuery: TenantQueries.memberships,
|
|
@@ -1310,288 +1141,3 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1310
1141
|
|
|
1311
1142
|
return handle;
|
|
1312
1143
|
}
|
|
1313
|
-
|
|
1314
|
-
// Static-fallback: try the Hono app first, fall back to a file in
|
|
1315
|
-
// staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
|
|
1316
|
-
// everything else (HTML, JS, CSS, images) on the disk.
|
|
1317
|
-
//
|
|
1318
|
-
// Cache-Header-Strategie:
|
|
1319
|
-
// /assets/* → public, max-age=31536000, immutable
|
|
1320
|
-
// (gehashte Filenames vom Build, sicher cachebar)
|
|
1321
|
-
// /index.html → no-cache, must-revalidate
|
|
1322
|
-
// (HTML-Shell, must reload on deploy)
|
|
1323
|
-
// /manifest.json, /sw.js → no-cache
|
|
1324
|
-
// (Update-Detection-Mechanismen, müssen frisch sein)
|
|
1325
|
-
// alles andere → kein expliziter Header
|
|
1326
|
-
// (Browser-Default, public/-Files wie favicon)
|
|
1327
|
-
// File-reader für den static-fallback. Nutzt node:fs/promises statt
|
|
1328
|
-
// Bun.file damit der Pfad in vitest+node integration-tests laufen kann
|
|
1329
|
-
// (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
|
|
1330
|
-
// Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
|
|
1331
|
-
// ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
|
|
1332
|
-
// ~1 MB.
|
|
1333
|
-
async function readStaticFile(
|
|
1334
|
-
filePath: string,
|
|
1335
|
-
): Promise<
|
|
1336
|
-
{ readonly bytes: Uint8Array; readonly mime: string; readonly mtimeMs: number } | undefined
|
|
1337
|
-
> {
|
|
1338
|
-
try {
|
|
1339
|
-
const { readFile, stat } = await import("node:fs/promises");
|
|
1340
|
-
const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
|
|
1341
|
-
return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
|
|
1342
|
-
} catch (err) {
|
|
1343
|
-
if ((err as { code?: string }).code === "ENOENT") return undefined;
|
|
1344
|
-
throw err;
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
function serveDiskFile(
|
|
1349
|
-
req: Request,
|
|
1350
|
-
pathname: string,
|
|
1351
|
-
file: {
|
|
1352
|
-
readonly bytes: Uint8Array;
|
|
1353
|
-
readonly mime: string;
|
|
1354
|
-
readonly mtimeMs: number;
|
|
1355
|
-
},
|
|
1356
|
-
): Response {
|
|
1357
|
-
return cachedResponse(req, {
|
|
1358
|
-
// @cast-boundary bun-types — Response BodyInit narrowing
|
|
1359
|
-
body: file.bytes as unknown as BodyInit,
|
|
1360
|
-
etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
|
|
1361
|
-
cache: staticCachePolicy(pathname),
|
|
1362
|
-
headers: { "content-type": file.mime },
|
|
1363
|
-
lastModified: new Date(file.mtimeMs),
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
// Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
|
|
1368
|
-
// public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
|
|
1369
|
-
// im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
|
|
1370
|
-
// fragt bei unbekanntem MIME nach).
|
|
1371
|
-
function mimeTypeFor(filePath: string): string {
|
|
1372
|
-
const ext = filePath.toLowerCase().split(".").pop() ?? "";
|
|
1373
|
-
switch (ext) {
|
|
1374
|
-
case "html":
|
|
1375
|
-
return "text/html; charset=utf-8";
|
|
1376
|
-
case "js":
|
|
1377
|
-
case "mjs":
|
|
1378
|
-
return "text/javascript; charset=utf-8";
|
|
1379
|
-
case "css":
|
|
1380
|
-
return "text/css; charset=utf-8";
|
|
1381
|
-
case "json":
|
|
1382
|
-
return "application/json; charset=utf-8";
|
|
1383
|
-
case "svg":
|
|
1384
|
-
return "image/svg+xml";
|
|
1385
|
-
case "png":
|
|
1386
|
-
return "image/png";
|
|
1387
|
-
case "jpg":
|
|
1388
|
-
case "jpeg":
|
|
1389
|
-
return "image/jpeg";
|
|
1390
|
-
case "ico":
|
|
1391
|
-
return "image/x-icon";
|
|
1392
|
-
case "txt":
|
|
1393
|
-
return "text/plain; charset=utf-8";
|
|
1394
|
-
case "xml":
|
|
1395
|
-
return "application/xml; charset=utf-8";
|
|
1396
|
-
case "webmanifest":
|
|
1397
|
-
return "application/manifest+json";
|
|
1398
|
-
default:
|
|
1399
|
-
return "application/octet-stream";
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
function buildStaticFallback(
|
|
1404
|
-
apiHandler: (req: Request) => Response | Promise<Response>,
|
|
1405
|
-
staticDir: string,
|
|
1406
|
-
appSchemaJson: string,
|
|
1407
|
-
hostDispatch?: HostDispatchFn,
|
|
1408
|
-
): (req: Request) => Promise<Response> {
|
|
1409
|
-
const indexHtml = `${staticDir}/index.html`;
|
|
1410
|
-
|
|
1411
|
-
// Helper: liest eine HTML-Datei von der Disk + (optional) injiziert
|
|
1412
|
-
// das pre-serialized AppSchema vor dem client.js-Tag. Schema-Injection
|
|
1413
|
-
// ist explicit-opt-in damit Public-Domain-Antworten die Admin-UI-
|
|
1414
|
-
// Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
|
|
1415
|
-
// produzieren keinen doppelten Tag.
|
|
1416
|
-
async function readHtmlFile(
|
|
1417
|
-
path: string,
|
|
1418
|
-
injectSchemaInto: boolean,
|
|
1419
|
-
): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number } | null> {
|
|
1420
|
-
const file = await readStaticFile(path);
|
|
1421
|
-
if (!file) return null;
|
|
1422
|
-
if (!injectSchemaInto) {
|
|
1423
|
-
return {
|
|
1424
|
-
bytes: file.bytes.buffer.slice(
|
|
1425
|
-
file.bytes.byteOffset,
|
|
1426
|
-
file.bytes.byteOffset + file.bytes.byteLength,
|
|
1427
|
-
) as ArrayBuffer,
|
|
1428
|
-
mime: file.mime,
|
|
1429
|
-
etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
|
|
1430
|
-
mtimeMs: file.mtimeMs,
|
|
1431
|
-
};
|
|
1432
|
-
}
|
|
1433
|
-
const text = new TextDecoder().decode(file.bytes);
|
|
1434
|
-
const injected = injectSchema(text, appSchemaJson);
|
|
1435
|
-
const bytes = new TextEncoder().encode(injected).buffer as ArrayBuffer;
|
|
1436
|
-
return {
|
|
1437
|
-
bytes,
|
|
1438
|
-
mime: file.mime,
|
|
1439
|
-
etag: computeStrongEtag(new Uint8Array(bytes)),
|
|
1440
|
-
mtimeMs: file.mtimeMs,
|
|
1441
|
-
};
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
function serveHtmlFile(
|
|
1445
|
-
req: Request,
|
|
1446
|
-
pathname: string,
|
|
1447
|
-
html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
|
|
1448
|
-
extraHeaders?: Record<string, string>,
|
|
1449
|
-
): Response {
|
|
1450
|
-
return cachedResponse(req, {
|
|
1451
|
-
body: html.bytes,
|
|
1452
|
-
etag: html.etag,
|
|
1453
|
-
cache: staticCachePolicy(pathname),
|
|
1454
|
-
headers: { "content-type": html.mime, ...extraHeaders },
|
|
1455
|
-
lastModified: new Date(html.mtimeMs),
|
|
1456
|
-
});
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
// hostDispatch konsultieren wenn gesetzt UND der Request auf den
|
|
1460
|
-
// HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
|
|
1461
|
-
// resolved Response (redirect/404/html) oder null wenn der Default-
|
|
1462
|
-
// Pfad weiterlaufen soll.
|
|
1463
|
-
async function tryHostDispatch(req: Request): Promise<Response | null> {
|
|
1464
|
-
if (!hostDispatch) return null;
|
|
1465
|
-
const url = new URL(req.url);
|
|
1466
|
-
const host = req.headers.get("host") ?? url.host;
|
|
1467
|
-
const result = hostDispatch({ host, path: url.pathname, search: url.search });
|
|
1468
|
-
if (result.kind === "not-found") {
|
|
1469
|
-
return new Response("Not Found", { status: 404 });
|
|
1470
|
-
}
|
|
1471
|
-
if (result.kind === "redirect") {
|
|
1472
|
-
return new Response(null, {
|
|
1473
|
-
status: result.status ?? 302,
|
|
1474
|
-
headers: { Location: result.to },
|
|
1475
|
-
});
|
|
1476
|
-
}
|
|
1477
|
-
// result.kind === "html"
|
|
1478
|
-
const filePath = `${staticDir}/${result.file}`;
|
|
1479
|
-
const html = await readHtmlFile(filePath, result.injectSchema === true);
|
|
1480
|
-
if (!html) {
|
|
1481
|
-
// Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
|
|
1482
|
-
// Liefer 500 statt silent-404 damit der Bug schnell auffällt.
|
|
1483
|
-
return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
|
|
1484
|
-
}
|
|
1485
|
-
// Per-Host-Body (hostDispatch wählt die Datei nach Host) → Vary: Host,
|
|
1486
|
-
// sonst darf ein Shared-Cache Tenant-As Schema an Tenant B liefern.
|
|
1487
|
-
const extraHeaders: Record<string, string> = { vary: "Host" };
|
|
1488
|
-
if (result.csp) extraHeaders["content-security-policy"] = result.csp;
|
|
1489
|
-
return serveHtmlFile(req, "/index.html", html, extraHeaders);
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
return async (req: Request): Promise<Response> => {
|
|
1493
|
-
const url = new URL(req.url);
|
|
1494
|
-
// /api/* and /health → always Hono (Dispatcher + Health-Probe).
|
|
1495
|
-
if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
|
|
1496
|
-
return apiHandler(req);
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
// Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
|
|
1500
|
-
// /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
|
|
1501
|
-
// dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
|
|
1502
|
-
// unbekannte Pfade als index.html. Shared mit dev-server's
|
|
1503
|
-
// createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
|
|
1504
|
-
const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
|
|
1505
|
-
if (honoTry.matched) {
|
|
1506
|
-
return honoTry.response;
|
|
1507
|
-
}
|
|
1508
|
-
const honoRes = honoTry.response;
|
|
1509
|
-
|
|
1510
|
-
// Disk-/SPA-Fallback ist GET/HEAD-only. Ein non-GET ohne Hono-Match
|
|
1511
|
-
// (z.B. POST auf einen falsch konfigurierten Webhook-Pfad) muss den
|
|
1512
|
-
// Hono-404 durchreichen — 200 index.html würde dem Provider
|
|
1513
|
-
// "delivered" signalisieren und Events gingen still verloren (#259).
|
|
1514
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1515
|
-
return honoRes;
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
|
|
1519
|
-
// host-unabhängig — die Bundles in /assets/* werden vom client
|
|
1520
|
-
// aktiv geladen, kein Server-side Routing nötig.
|
|
1521
|
-
const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
|
|
1522
|
-
if (!isIndexRequest) {
|
|
1523
|
-
const relPath = url.pathname.slice(1);
|
|
1524
|
-
const filePath = `${staticDir}/${relPath}`;
|
|
1525
|
-
const file = await readStaticFile(filePath);
|
|
1526
|
-
if (file) {
|
|
1527
|
-
return serveDiskFile(req, url.pathname, file);
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
// Root oder SPA-Route — hier greift hostDispatch wenn gesetzt.
|
|
1532
|
-
// Ohne hostDispatch: alter Single-App-Pfad (index.html mit Schema).
|
|
1533
|
-
const dispatched = await tryHostDispatch(req);
|
|
1534
|
-
if (dispatched) return dispatched;
|
|
1535
|
-
|
|
1536
|
-
// Default Single-App-Pfad: index.html, schema injected.
|
|
1537
|
-
const index = await readHtmlFile(indexHtml, true);
|
|
1538
|
-
if (index) {
|
|
1539
|
-
return serveHtmlFile(req, "/index.html", index);
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
// Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
|
|
1543
|
-
// ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
|
|
1544
|
-
return honoRes;
|
|
1545
|
-
};
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
// Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
|
|
1549
|
-
// unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
|
|
1550
|
-
// Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
|
|
1551
|
-
// runProdApp.
|
|
1552
|
-
export function staticCachePolicy(pathname: string): CachePolicy {
|
|
1553
|
-
if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
|
|
1554
|
-
return { kind: "immutable" };
|
|
1555
|
-
}
|
|
1556
|
-
if (pathname === "/" || pathname === "/index.html") {
|
|
1557
|
-
return { kind: "revalidate" };
|
|
1558
|
-
}
|
|
1559
|
-
if (
|
|
1560
|
-
pathname === "/manifest.json" ||
|
|
1561
|
-
pathname === "/sw.js" ||
|
|
1562
|
-
// ponytail: build-info.json ist statisch — kein /api/version-Endpoint
|
|
1563
|
-
// nötig, der Disk-Fallback serviert sie. no-cache, sonst pollt der
|
|
1564
|
-
// UpdateChecker eine veraltete id.
|
|
1565
|
-
pathname === "/build-info.json"
|
|
1566
|
-
) {
|
|
1567
|
-
return { kind: "no-cache" };
|
|
1568
|
-
}
|
|
1569
|
-
return { kind: "none" };
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
function buildProdSessionAuth(
|
|
1573
|
-
db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
|
|
1574
|
-
opts: ProdSessionsConfig,
|
|
1575
|
-
sessionsFeature: import("@cosmicdrift/kumiko-framework/engine").FeatureDefinition | undefined,
|
|
1576
|
-
): {
|
|
1577
|
-
readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
|
|
1578
|
-
readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
|
|
1579
|
-
readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
|
|
1580
|
-
readonly sessionStrictMode: true;
|
|
1581
|
-
} {
|
|
1582
|
-
const cbs = createSessionCallbacks({
|
|
1583
|
-
db,
|
|
1584
|
-
...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
|
|
1585
|
-
});
|
|
1586
|
-
// Secure-by-default: password-change/-reset mass-revokes the user's live
|
|
1587
|
-
// sessions without the app opting in via autoRevokeOnPasswordChange.
|
|
1588
|
-
if (sessionsFeature) {
|
|
1589
|
-
bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
|
|
1590
|
-
}
|
|
1591
|
-
return {
|
|
1592
|
-
sessionCreator: cbs.sessionCreator,
|
|
1593
|
-
sessionRevoker: cbs.sessionRevoker,
|
|
1594
|
-
sessionChecker: cbs.sessionChecker,
|
|
1595
|
-
sessionStrictMode: true,
|
|
1596
|
-
};
|
|
1597
|
-
}
|
|
@@ -138,7 +138,7 @@ function renderIndex(name: string): string {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
function renderConstants(name: string): string {
|
|
141
|
-
return `export const ${constName(name)} = "${name}";\n`;
|
|
141
|
+
return `export const ${constName(name)} = "${name}" as const;\n`;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function renderI18n(name: string): string {
|
|
@@ -158,12 +158,13 @@ function renderEntity(name: string): string {
|
|
|
158
158
|
const camel = kebabToCamel(name);
|
|
159
159
|
return `// Entity-Definition — Felder, Annotationen, Transitions.
|
|
160
160
|
// Starter: eine Text-Spalte. Ersetze sie durch deine Domain.
|
|
161
|
+
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
161
162
|
|
|
162
|
-
export const ${camel}ItemEntity = {
|
|
163
|
+
export const ${camel}ItemEntity = createEntity({
|
|
163
164
|
fields: {
|
|
164
|
-
title: {
|
|
165
|
+
title: createTextField({ required: true, sortable: true }),
|
|
165
166
|
},
|
|
166
|
-
};
|
|
167
|
+
});
|
|
167
168
|
`;
|
|
168
169
|
}
|
|
169
170
|
|
|
@@ -19,7 +19,7 @@ export type SetupTestStackFromFeaturesOptions = Omit<TestStackOptions, "features
|
|
|
19
19
|
readonly presets?: readonly TestStackPreset[];
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
function mergeExtraContext(
|
|
22
|
+
export function mergeExtraContext(
|
|
23
23
|
base: TestStackOptions["extraContext"],
|
|
24
24
|
presets: readonly TestStackPreset[],
|
|
25
25
|
): TestStackOptions["extraContext"] {
|