@krizaka/app-config 0.0.0-stage → 0.1.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/client.js ADDED
@@ -0,0 +1,142 @@
1
+ import {
2
+ APP_CONFIG_PATH,
3
+ CLIENT_HEADERS,
4
+ isAppConfigPayload
5
+ } from "./chunk-D3DIU5IW.js";
6
+
7
+ // src/client.ts
8
+ var DEFAULT_MIN_INTERVAL_MS = 3e5;
9
+ var DISMISS_KEY = "krizaka.app-config.soft-dismissed";
10
+ function decide(config, softDismissedFor) {
11
+ if (!config) return { kind: "ok" };
12
+ if (config.maintenance.on) return { kind: "maintenance", config };
13
+ if (config.updateMode === "force") return { kind: "force", config };
14
+ if (config.updateMode === "soft" && softDismissedFor !== config.latestVersion) return { kind: "soft", config };
15
+ return { kind: "ok" };
16
+ }
17
+ function clientHeaders(info) {
18
+ return {
19
+ [CLIENT_HEADERS.platform]: info.platform,
20
+ [CLIENT_HEADERS.version]: info.version,
21
+ [CLIENT_HEADERS.contract]: String(info.contract ?? 1),
22
+ ...info.build ? { [CLIENT_HEADERS.build]: info.build } : {}
23
+ };
24
+ }
25
+ function isUpgradeRequired(response) {
26
+ return response.status === 426;
27
+ }
28
+ function upgradePayloadOf(body) {
29
+ const appConfig = body?.appConfig;
30
+ return isAppConfigPayload(appConfig) ? normalise(appConfig) : null;
31
+ }
32
+ async function readUpgradeRequired(response) {
33
+ try {
34
+ return upgradePayloadOf(await response.clone().json());
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ function normalise(payload) {
40
+ return { ...payload, flags: payload.flags ?? {}, message: payload.message ?? null, storeUrl: payload.storeUrl ?? null, locale: payload.locale ?? "en" };
41
+ }
42
+ function withoutTrailingSlashes(url) {
43
+ let end = url.length;
44
+ while (end > 0 && url.endsWith("/", end)) end -= 1;
45
+ return url.slice(0, end);
46
+ }
47
+ function withDefaults(given, defaults) {
48
+ if (given instanceof Headers) {
49
+ const merged = new Headers(given);
50
+ for (const [name, value] of Object.entries(defaults)) if (!merged.has(name)) merged.set(name, value);
51
+ return merged;
52
+ }
53
+ const own = Array.isArray(given) ? Object.fromEntries(given) : { ...given };
54
+ const known = new Set(Object.keys(own).map((name) => name.toLowerCase()));
55
+ for (const [name, value] of Object.entries(defaults)) if (!known.has(name.toLowerCase())) own[name] = value;
56
+ return own;
57
+ }
58
+ function createAppConfigClient(options) {
59
+ const base = withoutTrailingSlashes(options.baseUrl);
60
+ const doFetch = options.fetch ?? ((...args) => fetch(...args));
61
+ const now = options.now ?? Date.now;
62
+ const interval = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
63
+ const headers = clientHeaders(options);
64
+ const listeners = /* @__PURE__ */ new Set();
65
+ let dismissed = null;
66
+ let lastCheck = Number.NEGATIVE_INFINITY;
67
+ let state = { status: "idle", config: null, decision: { kind: "ok" }, upgradeRequired: null };
68
+ const set = (patch) => {
69
+ state = { ...state, ...patch };
70
+ state = { ...state, decision: state.upgradeRequired ? { kind: "force", config: state.upgradeRequired } : decide(state.config, dismissed) };
71
+ for (const listener of listeners) listener();
72
+ };
73
+ const client = {
74
+ headers,
75
+ getState: () => state,
76
+ subscribe(listener) {
77
+ listeners.add(listener);
78
+ return () => void listeners.delete(listener);
79
+ },
80
+ async check({ force = false } = {}) {
81
+ if (!force && now() - lastCheck < interval) return;
82
+ lastCheck = now();
83
+ set({ status: "loading" });
84
+ try {
85
+ if (options.storage && dismissed === null) dismissed = await options.storage.getItem(DISMISS_KEY) ?? "";
86
+ const query = new URLSearchParams({ platform: options.platform, version: options.version });
87
+ const language = options.language?.();
88
+ if (language) query.set("locale", language);
89
+ const response = await doFetch(`${base}${APP_CONFIG_PATH}?${query}`, { headers });
90
+ const body = response.ok ? await response.json() : null;
91
+ if (!isAppConfigPayload(body)) {
92
+ set({ status: "failed" });
93
+ return;
94
+ }
95
+ set({ status: "ready", config: normalise(body) });
96
+ } catch {
97
+ set({ status: "failed" });
98
+ }
99
+ },
100
+ async dismissSoft() {
101
+ const latest = state.config?.latestVersion;
102
+ if (!latest) return;
103
+ dismissed = latest;
104
+ await options.storage?.setItem(DISMISS_KEY, latest);
105
+ set({});
106
+ },
107
+ reportUpgradeRequired(payload) {
108
+ const known = payload ?? state.config;
109
+ const fallback = {
110
+ platform: options.platform,
111
+ minSupportedVersion: known?.minSupportedVersion ?? options.version,
112
+ latestVersion: known?.latestVersion ?? options.version,
113
+ updateMode: "force",
114
+ storeUrl: known?.storeUrl ?? null,
115
+ maintenance: { on: false, message: null },
116
+ contract: known?.contract ?? { min: 1, max: 1, sunset: null },
117
+ flags: known?.flags ?? {},
118
+ message: known?.message ?? null,
119
+ locale: known?.locale ?? "en"
120
+ };
121
+ set({ upgradeRequired: { ...fallback, updateMode: "force" } });
122
+ },
123
+ fetch: async (input, init) => {
124
+ const response = await doFetch(input, { ...init, headers: withDefaults(init?.headers, headers) });
125
+ if (isUpgradeRequired(response)) client.reportUpgradeRequired(await readUpgradeRequired(response));
126
+ return response;
127
+ },
128
+ attach(resume) {
129
+ return resume(() => void client.check());
130
+ }
131
+ };
132
+ return client;
133
+ }
134
+ export {
135
+ clientHeaders,
136
+ createAppConfigClient,
137
+ decide,
138
+ isUpgradeRequired,
139
+ readUpgradeRequired,
140
+ upgradePayloadOf
141
+ };
142
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts"],"sourcesContent":["import {\n APP_CONFIG_PATH,\n type AppConfigPayload,\n CLIENT_HEADERS,\n isAppConfigPayload,\n type Platform,\n} from \"./core\";\n\n/** What the app must show, decided from the payload. Maintenance outranks an update. */\nexport type Decision =\n | { kind: \"ok\" }\n | { kind: \"soft\"; config: AppConfigPayload }\n | { kind: \"force\"; config: AppConfigPayload }\n | { kind: \"maintenance\"; config: AppConfigPayload };\n\nexport type AppConfigStatus = \"idle\" | \"loading\" | \"ready\" | \"failed\";\n\nexport interface AppConfigState {\n status: AppConfigStatus;\n config: AppConfigPayload | null;\n decision: Decision;\n /** The 426 payload that forced the screen, if a request got one. */\n upgradeRequired: AppConfigPayload | null;\n}\n\n/** A place to remember which version's soft prompt the person already dismissed. */\nexport interface DismissStorage {\n getItem(key: string): string | null | Promise<string | null>;\n setItem(key: string, value: string): void | Promise<void>;\n}\n\nexport interface AppConfigClientOptions {\n /** The backend's origin, e.g. `https://dev.orazaka.com`. */\n baseUrl: string;\n platform: Platform;\n /** The version the app runs (`1.4.0`), from the build, never typed twice. */\n version: string;\n /** The API contract the app speaks. Defaults to 1. */\n contract?: number;\n build?: string;\n fetch?: typeof fetch;\n /** The language to ask texts in (`fr`); defaults to the backend's `Accept-Language` reading. */\n language?: () => string | undefined;\n storage?: DismissStorage;\n now?: () => number;\n /** A check requested sooner than this after the last one is skipped (resume storms). Default 300 s. */\n minIntervalMs?: number;\n}\n\n// @fixed: interface setting — a resume within five minutes of the last check is not worth a request.\nconst DEFAULT_MIN_INTERVAL_MS = 300_000;\nconst DISMISS_KEY = \"krizaka.app-config.soft-dismissed\";\n\n/** Maintenance first, then a forced update, then a soft one. */\nexport function decide(config: AppConfigPayload | null, softDismissedFor?: string | null): Decision {\n if (!config) return { kind: \"ok\" };\n if (config.maintenance.on) return { kind: \"maintenance\", config };\n if (config.updateMode === \"force\") return { kind: \"force\", config };\n if (config.updateMode === \"soft\" && softDismissedFor !== config.latestVersion) return { kind: \"soft\", config };\n return { kind: \"ok\" };\n}\n\n/** The headers every request of the app carries. */\nexport function clientHeaders(info: { platform: Platform; version: string; contract?: number; build?: string }): Record<string, string> {\n return {\n [CLIENT_HEADERS.platform]: info.platform,\n [CLIENT_HEADERS.version]: info.version,\n [CLIENT_HEADERS.contract]: String(info.contract ?? 1),\n ...(info.build ? { [CLIENT_HEADERS.build]: info.build } : {}),\n };\n}\n\n/** Whether a response is the backend telling the app to update. */\nexport function isUpgradeRequired(response: { status: number }): boolean {\n return response.status === 426;\n}\n\n/** The `appConfig` member of an already parsed `426` body, or `null` (for a transport that is not `fetch`, e.g. XHR). */\nexport function upgradePayloadOf(body: unknown): AppConfigPayload | null {\n const appConfig = (body as { appConfig?: unknown } | null)?.appConfig;\n return isAppConfigPayload(appConfig) ? normalise(appConfig) : null;\n}\n\n/** The `appConfig` of a `426` response, or `null` when the body is not one (the app still must update). */\nexport async function readUpgradeRequired(response: Response): Promise<AppConfigPayload | null> {\n try {\n return upgradePayloadOf(await response.clone().json());\n } catch {\n return null;\n }\n}\n\nfunction normalise(payload: AppConfigPayload): AppConfigPayload {\n return { ...payload, flags: payload.flags ?? {}, message: payload.message ?? null, storeUrl: payload.storeUrl ?? null, locale: payload.locale ?? \"en\" };\n}\n\n/** `https://x//` → `https://x` (a loop, not a regular expression: no backtracking on a long run of slashes). */\nfunction withoutTrailingSlashes(url: string): string {\n let end = url.length;\n while (end > 0 && url.endsWith(\"/\", end)) end -= 1;\n return url.slice(0, end);\n}\n\n/** The caller's headers with ours added where it has not set them (names compare case-insensitively). A plain object stays one. */\nfunction withDefaults(given: NonNullable<RequestInit[\"headers\"]> | undefined, defaults: Readonly<Record<string, string>>): NonNullable<RequestInit[\"headers\"]> {\n if (given instanceof Headers) {\n const merged = new Headers(given);\n for (const [name, value] of Object.entries(defaults)) if (!merged.has(name)) merged.set(name, value);\n return merged;\n }\n const own: Record<string, string> = Array.isArray(given) ? Object.fromEntries(given) : { ...given };\n const known = new Set(Object.keys(own).map((name) => name.toLowerCase()));\n for (const [name, value] of Object.entries(defaults)) if (!known.has(name.toLowerCase())) own[name] = value;\n return own;\n}\n\nexport interface AppConfigClient {\n /** The headers every request carries — for a transport that is not `fetch` (XHR, a native module). */\n readonly headers: Readonly<Record<string, string>>;\n getState(): AppConfigState;\n subscribe(listener: () => void): () => void;\n /** Checks the backend. Skipped when a check ran less than `minIntervalMs` ago, unless `force`. Never throws. */\n check(options?: { force?: boolean }): Promise<void>;\n /** The person dismissed the soft prompt: not shown again for this `latestVersion`. */\n dismissSoft(): Promise<void>;\n /** A request got a 426 (the global interceptor calls this). */\n reportUpgradeRequired(payload: AppConfigPayload | null): void;\n /** `fetch` that sends the client headers and reports every 426 to this client. */\n fetch: typeof fetch;\n /** Checks again each time `resume` fires (app foreground, tab visible). Returns the unsubscribe. */\n attach(resume: (callback: () => void) => () => void): () => void;\n}\n\nexport function createAppConfigClient(options: AppConfigClientOptions): AppConfigClient {\n const base = withoutTrailingSlashes(options.baseUrl);\n const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));\n const now = options.now ?? Date.now;\n const interval = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;\n const headers = clientHeaders(options);\n const listeners = new Set<() => void>();\n let dismissed: string | null = null;\n let lastCheck = Number.NEGATIVE_INFINITY;\n let state: AppConfigState = { status: \"idle\", config: null, decision: { kind: \"ok\" }, upgradeRequired: null };\n\n const set = (patch: Partial<AppConfigState>) => {\n state = { ...state, ...patch };\n state = { ...state, decision: state.upgradeRequired ? { kind: \"force\", config: state.upgradeRequired } : decide(state.config, dismissed) };\n for (const listener of listeners) listener();\n };\n\n const client: AppConfigClient = {\n headers,\n getState: () => state,\n subscribe(listener) {\n listeners.add(listener);\n return () => void listeners.delete(listener);\n },\n async check({ force = false } = {}) {\n if (!force && now() - lastCheck < interval) return;\n lastCheck = now();\n set({ status: \"loading\" });\n try {\n if (options.storage && dismissed === null) dismissed = (await options.storage.getItem(DISMISS_KEY)) ?? \"\";\n const query = new URLSearchParams({ platform: options.platform, version: options.version });\n const language = options.language?.();\n if (language) query.set(\"locale\", language);\n const response = await doFetch(`${base}${APP_CONFIG_PATH}?${query}`, { headers });\n const body: unknown = response.ok ? await response.json() : null;\n if (!isAppConfigPayload(body)) {\n set({ status: \"failed\" });\n return;\n }\n set({ status: \"ready\", config: normalise(body) });\n } catch {\n // Fail open: an unreachable or broken config endpoint never locks the app.\n set({ status: \"failed\" });\n }\n },\n async dismissSoft() {\n const latest = state.config?.latestVersion;\n if (!latest) return;\n dismissed = latest;\n await options.storage?.setItem(DISMISS_KEY, latest);\n set({});\n },\n reportUpgradeRequired(payload) {\n const known = payload ?? state.config;\n // A 426 without a usable body still blocks: the person must update, whatever the store says.\n const fallback: AppConfigPayload = {\n platform: options.platform,\n minSupportedVersion: known?.minSupportedVersion ?? options.version,\n latestVersion: known?.latestVersion ?? options.version,\n updateMode: \"force\",\n storeUrl: known?.storeUrl ?? null,\n maintenance: { on: false, message: null },\n contract: known?.contract ?? { min: 1, max: 1, sunset: null },\n flags: known?.flags ?? {},\n message: known?.message ?? null,\n locale: known?.locale ?? \"en\",\n };\n set({ upgradeRequired: { ...fallback, updateMode: \"force\" } });\n },\n fetch: async (input, init) => {\n const response = await doFetch(input, { ...init, headers: withDefaults(init?.headers, headers) });\n if (isUpgradeRequired(response)) client.reportUpgradeRequired(await readUpgradeRequired(response));\n return response;\n },\n attach(resume) {\n return resume(() => void client.check());\n },\n };\n return client;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The contract, pure: versions, the payload, and the rules that decide what happens to a client.
3
+ * Twin of the Java building block `krizaka-app-config`; `test/cases.json` is the fixture both obey.
4
+ */
5
+ declare const PLATFORMS: readonly ["ios", "android", "macos", "windows", "linux", "web"];
6
+ type Platform = (typeof PLATFORMS)[number];
7
+ declare const UPDATE_MODES: readonly ["none", "soft", "force"];
8
+ type UpdateMode = (typeof UPDATE_MODES)[number];
9
+ /** The headers an app sends and the backend answers with. */
10
+ declare const CLIENT_HEADERS: {
11
+ readonly version: "X-Client-Version";
12
+ readonly platform: "X-Client-Platform";
13
+ readonly contract: "X-Contract-Version";
14
+ readonly build: "X-Client-Build";
15
+ readonly deprecation: "Deprecation";
16
+ readonly sunset: "Sunset";
17
+ };
18
+ /** The public endpoint's path. */
19
+ declare const APP_CONFIG_PATH = "/api/v1/app-config";
20
+ type FlagValue = boolean | number | string;
21
+ /** What `GET /api/v1/app-config` returns and what a `426` carries as `appConfig`. */
22
+ interface AppConfigPayload {
23
+ platform: Platform;
24
+ minSupportedVersion: string;
25
+ latestVersion: string;
26
+ updateMode: UpdateMode;
27
+ storeUrl: string | null;
28
+ maintenance: {
29
+ on: boolean;
30
+ message: string | null;
31
+ };
32
+ contract: {
33
+ min: number;
34
+ max: number;
35
+ sunset: string | null;
36
+ };
37
+ flags: Record<string, FlagValue>;
38
+ message: string | null;
39
+ locale: string;
40
+ }
41
+ /** The parameters of one platform, as the database holds them (the admin edits this shape). */
42
+ interface AppConfigRecord {
43
+ platform: Platform;
44
+ minSupportedVersion: string;
45
+ latestVersion: string;
46
+ updateOverride: UpdateMode | null;
47
+ storeUrl: string | null;
48
+ maintenanceOn: boolean;
49
+ maintenanceMessage: Record<string, string>;
50
+ message: Record<string, string>;
51
+ contractMin: number;
52
+ contractMax: number;
53
+ contractSunset: string | null;
54
+ flags: Record<string, FlagValue>;
55
+ revision: number;
56
+ updatedAt: string;
57
+ updatedBy: string;
58
+ }
59
+ /** What a client says about itself (headers or query). */
60
+ interface ClientInfo {
61
+ platform?: string | null;
62
+ version?: string | null;
63
+ contract?: string | number | null;
64
+ language?: string | null;
65
+ }
66
+ type Version = readonly [major: number, minor: number, patch: number];
67
+ /** `1.4`, `v1.4.0`, `1.4.0-beta.2` and `1.4.0+42` all read as `[1, 4, 0]`; anything else is `null`. */
68
+ declare function parseVersion(text: string | null | undefined): Version | null;
69
+ declare function compareVersions(a: Version, b: Version): number;
70
+ declare function formatVersion(version: Version): string;
71
+ declare function isPlatform(value: unknown): value is Platform;
72
+ declare function toPlatform(value: string | null | undefined): Platform | null;
73
+ /** The permissive parameters a platform starts with: nobody is locked out. */
74
+ declare function defaultRecord(platform: Platform): AppConfigRecord;
75
+ /** The safety bounds of an edit; an empty list means the record can be published. */
76
+ declare function validateRecord(record: AppConfigRecord): string[];
77
+ /** `none`, `soft` or `force` for the version the client runs. The operator's override wins. */
78
+ declare function updateModeFor(record: AppConfigRecord, client: Version | null): UpdateMode;
79
+ /** The public payload for a client, its texts in the best language available. */
80
+ declare function buildPayload(record: AppConfigRecord, client: Version | null, language?: string | null): AppConfigPayload;
81
+ type GuardCode = "upgrade-required" | "contract-unsupported";
82
+ /** What the 426 filter does with a request. */
83
+ type GuardResult = {
84
+ /** `Deprecation: true` — the contract the client speaks is served but on its way out. */
85
+ deprecation: boolean;
86
+ /** The newest contract the backend speaks (`X-Contract-Version` of the answer). */
87
+ contract: number;
88
+ } & ({
89
+ status: 200;
90
+ /** The `Sunset` HTTP date, when announced. */
91
+ sunset?: string;
92
+ } | {
93
+ status: 426;
94
+ code: GuardCode;
95
+ /** The `appConfig` of a 426: the payload with `updateMode: "force"`. */
96
+ payload: AppConfigPayload;
97
+ });
98
+ /** The 426 filter's decision: below the minimum or an unserved contract is refused; N-1 is deprecated. */
99
+ declare function evaluateGuard(record: AppConfigRecord, client: ClientInfo): GuardResult;
100
+ /** A structural check of a payload read from the network: anything else is ignored (fail open). */
101
+ declare function isAppConfigPayload(value: unknown): value is AppConfigPayload;
102
+
103
+ export { APP_CONFIG_PATH, type AppConfigPayload, type AppConfigRecord, CLIENT_HEADERS, type ClientInfo, type FlagValue, type GuardCode, type GuardResult, PLATFORMS, type Platform, UPDATE_MODES, type UpdateMode, type Version, buildPayload, compareVersions, defaultRecord, evaluateGuard, formatVersion, isAppConfigPayload, isPlatform, parseVersion, toPlatform, updateModeFor, validateRecord };
package/dist/index.js ADDED
@@ -0,0 +1,35 @@
1
+ import {
2
+ APP_CONFIG_PATH,
3
+ CLIENT_HEADERS,
4
+ PLATFORMS,
5
+ UPDATE_MODES,
6
+ buildPayload,
7
+ compareVersions,
8
+ defaultRecord,
9
+ evaluateGuard,
10
+ formatVersion,
11
+ isAppConfigPayload,
12
+ isPlatform,
13
+ parseVersion,
14
+ toPlatform,
15
+ updateModeFor,
16
+ validateRecord
17
+ } from "./chunk-D3DIU5IW.js";
18
+ export {
19
+ APP_CONFIG_PATH,
20
+ CLIENT_HEADERS,
21
+ PLATFORMS,
22
+ UPDATE_MODES,
23
+ buildPayload,
24
+ compareVersions,
25
+ defaultRecord,
26
+ evaluateGuard,
27
+ formatVersion,
28
+ isAppConfigPayload,
29
+ isPlatform,
30
+ parseVersion,
31
+ toPlatform,
32
+ updateModeFor,
33
+ validateRecord
34
+ };
35
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,31 @@
1
+ import { AppConfigState, AppConfigClient } from './client.js';
2
+ export { Decision } from './client.js';
3
+ import './index.js';
4
+
5
+ /** A source of "the app came back": call the callback each time, return the unsubscribe. */
6
+ type ResumeSource = (callback: () => void) => () => void;
7
+ /** React Native: `appStateResume(AppState)` — fires when the app returns to the foreground. */
8
+ declare function appStateResume(appState: {
9
+ addEventListener(type: "change", listener: (state: string) => void): {
10
+ remove(): void;
11
+ };
12
+ }): ResumeSource;
13
+ /** Web and desktop (a WebView): fires when the tab or window becomes visible again. */
14
+ declare function visibilityResume(target: {
15
+ addEventListener(type: "visibilitychange", listener: () => void): void;
16
+ removeEventListener(type: "visibilitychange", listener: () => void): void;
17
+ visibilityState: string;
18
+ }): ResumeSource;
19
+ interface UseAppConfig extends AppConfigState {
20
+ /** Dismiss the soft prompt for this `latestVersion`. */
21
+ dismissSoft(): void;
22
+ /** Check now, ignoring the minimum interval. */
23
+ refresh(): void;
24
+ }
25
+ /**
26
+ * The app's version state: checks at launch, again on every `resume`, and follows the 426 that any
27
+ * request made through `client.fetch` reports.
28
+ */
29
+ declare function useAppConfig(client: AppConfigClient, resume?: ResumeSource): UseAppConfig;
30
+
31
+ export { AppConfigClient, AppConfigState, type ResumeSource, type UseAppConfig, appStateResume, useAppConfig, visibilityResume };
package/dist/react.js ADDED
@@ -0,0 +1,35 @@
1
+ // src/react.ts
2
+ import { useCallback, useEffect, useSyncExternalStore } from "react";
3
+ function appStateResume(appState) {
4
+ return (callback) => {
5
+ const subscription = appState.addEventListener("change", (state) => {
6
+ if (state === "active") callback();
7
+ });
8
+ return () => subscription.remove();
9
+ };
10
+ }
11
+ function visibilityResume(target) {
12
+ return (callback) => {
13
+ const listener = () => {
14
+ if (target.visibilityState === "visible") callback();
15
+ };
16
+ target.addEventListener("visibilitychange", listener);
17
+ return () => target.removeEventListener("visibilitychange", listener);
18
+ };
19
+ }
20
+ function useAppConfig(client, resume) {
21
+ const state = useSyncExternalStore(client.subscribe, client.getState, client.getState);
22
+ useEffect(() => {
23
+ void client.check({ force: true });
24
+ return resume ? client.attach(resume) : void 0;
25
+ }, [client, resume]);
26
+ const dismissSoft = useCallback(() => void client.dismissSoft(), [client]);
27
+ const refresh = useCallback(() => void client.check({ force: true }), [client]);
28
+ return { ...state, dismissSoft, refresh };
29
+ }
30
+ export {
31
+ appStateResume,
32
+ useAppConfig,
33
+ visibilityResume
34
+ };
35
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react.ts"],"sourcesContent":["import { useCallback, useEffect, useSyncExternalStore } from \"react\";\n\nimport type { AppConfigClient, AppConfigState } from \"./client\";\n\nexport type { AppConfigClient, AppConfigState, Decision } from \"./client\";\n\n/** A source of \"the app came back\": call the callback each time, return the unsubscribe. */\nexport type ResumeSource = (callback: () => void) => () => void;\n\n/** React Native: `appStateResume(AppState)` — fires when the app returns to the foreground. */\nexport function appStateResume(appState: {\n addEventListener(type: \"change\", listener: (state: string) => void): { remove(): void };\n}): ResumeSource {\n return (callback) => {\n const subscription = appState.addEventListener(\"change\", (state) => {\n if (state === \"active\") callback();\n });\n return () => subscription.remove();\n };\n}\n\n/** Web and desktop (a WebView): fires when the tab or window becomes visible again. */\nexport function visibilityResume(target: {\n addEventListener(type: \"visibilitychange\", listener: () => void): void;\n removeEventListener(type: \"visibilitychange\", listener: () => void): void;\n visibilityState: string;\n}): ResumeSource {\n return (callback) => {\n const listener = () => {\n if (target.visibilityState === \"visible\") callback();\n };\n target.addEventListener(\"visibilitychange\", listener);\n return () => target.removeEventListener(\"visibilitychange\", listener);\n };\n}\n\nexport interface UseAppConfig extends AppConfigState {\n /** Dismiss the soft prompt for this `latestVersion`. */\n dismissSoft(): void;\n /** Check now, ignoring the minimum interval. */\n refresh(): void;\n}\n\n/**\n * The app's version state: checks at launch, again on every `resume`, and follows the 426 that any\n * request made through `client.fetch` reports.\n */\nexport function useAppConfig(client: AppConfigClient, resume?: ResumeSource): UseAppConfig {\n const state = useSyncExternalStore(client.subscribe, client.getState, client.getState);\n useEffect(() => {\n void client.check({ force: true });\n return resume ? client.attach(resume) : undefined;\n }, [client, resume]);\n const dismissSoft = useCallback(() => void client.dismissSoft(), [client]);\n const refresh = useCallback(() => void client.check({ force: true }), [client]);\n return { ...state, dismissSoft, refresh };\n}\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,4BAA4B;AAUtD,SAAS,eAAe,UAEd;AACf,SAAO,CAAC,aAAa;AACnB,UAAM,eAAe,SAAS,iBAAiB,UAAU,CAAC,UAAU;AAClE,UAAI,UAAU,SAAU,UAAS;AAAA,IACnC,CAAC;AACD,WAAO,MAAM,aAAa,OAAO;AAAA,EACnC;AACF;AAGO,SAAS,iBAAiB,QAIhB;AACf,SAAO,CAAC,aAAa;AACnB,UAAM,WAAW,MAAM;AACrB,UAAI,OAAO,oBAAoB,UAAW,UAAS;AAAA,IACrD;AACA,WAAO,iBAAiB,oBAAoB,QAAQ;AACpD,WAAO,MAAM,OAAO,oBAAoB,oBAAoB,QAAQ;AAAA,EACtE;AACF;AAaO,SAAS,aAAa,QAAyB,QAAqC;AACzF,QAAM,QAAQ,qBAAqB,OAAO,WAAW,OAAO,UAAU,OAAO,QAAQ;AACrF,YAAU,MAAM;AACd,SAAK,OAAO,MAAM,EAAE,OAAO,KAAK,CAAC;AACjC,WAAO,SAAS,OAAO,OAAO,MAAM,IAAI;AAAA,EAC1C,GAAG,CAAC,QAAQ,MAAM,CAAC;AACnB,QAAM,cAAc,YAAY,MAAM,KAAK,OAAO,YAAY,GAAG,CAAC,MAAM,CAAC;AACzE,QAAM,UAAU,YAAY,MAAM,KAAK,OAAO,MAAM,EAAE,OAAO,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC;AAC9E,SAAO,EAAE,GAAG,OAAO,aAAa,QAAQ;AAC1C;","names":[]}
@@ -0,0 +1,63 @@
1
+ import { Platform, AppConfigRecord, GuardResult } from './index.js';
2
+ export { APP_CONFIG_PATH } from './index.js';
3
+
4
+ /**
5
+ * The tables of the contract, for a Next.js backend's own migration. The same DDL ships with the Java building block
6
+ * (`krizaka-app-config-schema.sql`, copied verbatim to `contract/` and checked by a test): one schema, whichever stack
7
+ * serves it. Version 1 is seeded permissive (no minimum, no maintenance, one contract).
8
+ */
9
+ declare const schemaSql = "-- krizaka-app-config: the parameters of the app-version contract, one row per platform.\n-- Version 1 is seeded here (permissive: no minimum, no maintenance, one contract); a product's own\n-- migration publishes its real values through the admin API, never by editing the row by hand.\nCREATE TABLE IF NOT EXISTS app_config (\n platform VARCHAR(16) PRIMARY KEY,\n min_supported_version VARCHAR(32) NOT NULL DEFAULT '0.0.0',\n latest_version VARCHAR(32) NOT NULL DEFAULT '0.0.0',\n update_override VARCHAR(8),\n store_url TEXT,\n maintenance_on BOOLEAN NOT NULL DEFAULT FALSE,\n maintenance_message JSONB NOT NULL DEFAULT '{}'::jsonb,\n message JSONB NOT NULL DEFAULT '{}'::jsonb,\n contract_min INTEGER NOT NULL DEFAULT 1,\n contract_max INTEGER NOT NULL DEFAULT 1,\n contract_sunset DATE,\n flags JSONB NOT NULL DEFAULT '{}'::jsonb,\n revision INTEGER NOT NULL DEFAULT 1,\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_by VARCHAR(255) NOT NULL DEFAULT 'seed',\n CONSTRAINT app_config_platform_known CHECK (platform IN ('ios', 'android', 'macos', 'windows', 'linux', 'web')),\n CONSTRAINT app_config_contract_range CHECK (contract_min >= 1 AND contract_max >= contract_min)\n);\n\n-- Every published revision, append-only: the audit trail and the source of a rollback.\nCREATE TABLE IF NOT EXISTS app_config_history (\n id BIGSERIAL PRIMARY KEY,\n platform VARCHAR(16) NOT NULL,\n revision INTEGER NOT NULL,\n min_supported_version VARCHAR(32) NOT NULL,\n latest_version VARCHAR(32) NOT NULL,\n update_override VARCHAR(8),\n store_url TEXT,\n maintenance_on BOOLEAN NOT NULL,\n maintenance_message JSONB NOT NULL,\n message JSONB NOT NULL,\n contract_min INTEGER NOT NULL,\n contract_max INTEGER NOT NULL,\n contract_sunset DATE,\n flags JSONB NOT NULL,\n reason TEXT NOT NULL,\n changed_by VARCHAR(255) NOT NULL,\n changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n CONSTRAINT app_config_history_revision UNIQUE (platform, revision)\n);\n\nINSERT INTO app_config (platform)\nVALUES ('ios'), ('android'), ('macos'), ('windows'), ('linux'), ('web')\nON CONFLICT (platform) DO NOTHING;\n\nINSERT INTO app_config_history (platform, revision, min_supported_version, latest_version, update_override,\n store_url, maintenance_on, maintenance_message, message, contract_min,\n contract_max, contract_sunset, flags, reason, changed_by, changed_at)\nSELECT platform, revision, min_supported_version, latest_version, update_override, store_url, maintenance_on,\n maintenance_message, message, contract_min, contract_max, contract_sunset, flags, 'seed', updated_by,\n updated_at\nFROM app_config\nON CONFLICT (platform, revision) DO NOTHING;\n";
10
+
11
+ /** Where a Next.js backend keeps the parameters; the tables are `schemaSql`. */
12
+ interface AppConfigStore {
13
+ get(platform: Platform): Promise<AppConfigRecord | undefined>;
14
+ }
15
+ /** A store over fixed records: tests and demos. */
16
+ declare function memoryStore(records?: readonly AppConfigRecord[]): AppConfigStore;
17
+ interface CacheOptions {
18
+ /** How long a read value is reused. */
19
+ ttlMs: number;
20
+ now?: () => number;
21
+ }
22
+ /**
23
+ * Reads through a short cache and never lets the store take the API down: on a failure the last value is
24
+ * served, and with none the permissive defaults.
25
+ */
26
+ declare function cachedStore(store: AppConfigStore, { ttlMs, now }: CacheOptions): AppConfigStore & {
27
+ evict(platform?: Platform): void;
28
+ };
29
+ interface HandlerOptions {
30
+ store: AppConfigStore;
31
+ /** The public `max-age`, seconds. Defaults to 60. */
32
+ maxAgeSeconds?: number;
33
+ }
34
+ /**
35
+ * `GET /api/v1/app-config` for a route handler: public, cacheable, `ETag`, `304`. Mount it at
36
+ * `app/api/v1/app-config/route.ts`: `export const GET = createAppConfigHandler({ store })`.
37
+ */
38
+ declare function createAppConfigHandler({ store, maxAgeSeconds }: HandlerOptions): (request: Request) => Promise<Response>;
39
+ /** What the guard decided, ready to apply to a response. */
40
+ interface GuardOutcome {
41
+ /** The `426` to return, or `null` to continue. */
42
+ refusal: Response | null;
43
+ /** `X-Contract-Version`, `Deprecation`, `Sunset` — set them on the response you return. */
44
+ headers: Record<string, string>;
45
+ result: GuardResult | null;
46
+ }
47
+ interface GuardOptions {
48
+ store: AppConfigStore;
49
+ /** The base of Problem Details `type`. Defaults to `https://krizaka.com/problems/`. */
50
+ problemBase?: string;
51
+ }
52
+ /**
53
+ * The `426 Upgrade Required` filter for a Next.js backend (a route handler, or the proxy/middleware). A request
54
+ * without `X-Client-Platform` is not an app and passes untouched.
55
+ */
56
+ declare function checkClientVersion(request: Request, { store, problemBase }: GuardOptions): Promise<GuardOutcome>;
57
+ /**
58
+ * Wraps a route handler: an app below the minimum gets the `426` before the handler runs; otherwise the
59
+ * handler's response carries the contract headers. `APP_CONFIG_PATH` itself should not be wrapped.
60
+ */
61
+ declare function withClientVersionGuard<A extends unknown[]>(options: GuardOptions, handler: (request: Request, ...args: A) => Response | Promise<Response>): (request: Request, ...args: A) => Promise<Response>;
62
+
63
+ export { type AppConfigStore, type CacheOptions, type GuardOptions, type GuardOutcome, type HandlerOptions, cachedStore, checkClientVersion, createAppConfigHandler, memoryStore, schemaSql, withClientVersionGuard };
package/dist/server.js ADDED
@@ -0,0 +1,176 @@
1
+ import {
2
+ APP_CONFIG_PATH,
3
+ CLIENT_HEADERS,
4
+ buildPayload,
5
+ defaultRecord,
6
+ evaluateGuard,
7
+ parseVersion,
8
+ toPlatform
9
+ } from "./chunk-D3DIU5IW.js";
10
+
11
+ // src/schema.ts
12
+ var schemaSql = `-- krizaka-app-config: the parameters of the app-version contract, one row per platform.
13
+ -- Version 1 is seeded here (permissive: no minimum, no maintenance, one contract); a product's own
14
+ -- migration publishes its real values through the admin API, never by editing the row by hand.
15
+ CREATE TABLE IF NOT EXISTS app_config (
16
+ platform VARCHAR(16) PRIMARY KEY,
17
+ min_supported_version VARCHAR(32) NOT NULL DEFAULT '0.0.0',
18
+ latest_version VARCHAR(32) NOT NULL DEFAULT '0.0.0',
19
+ update_override VARCHAR(8),
20
+ store_url TEXT,
21
+ maintenance_on BOOLEAN NOT NULL DEFAULT FALSE,
22
+ maintenance_message JSONB NOT NULL DEFAULT '{}'::jsonb,
23
+ message JSONB NOT NULL DEFAULT '{}'::jsonb,
24
+ contract_min INTEGER NOT NULL DEFAULT 1,
25
+ contract_max INTEGER NOT NULL DEFAULT 1,
26
+ contract_sunset DATE,
27
+ flags JSONB NOT NULL DEFAULT '{}'::jsonb,
28
+ revision INTEGER NOT NULL DEFAULT 1,
29
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
30
+ updated_by VARCHAR(255) NOT NULL DEFAULT 'seed',
31
+ CONSTRAINT app_config_platform_known CHECK (platform IN ('ios', 'android', 'macos', 'windows', 'linux', 'web')),
32
+ CONSTRAINT app_config_contract_range CHECK (contract_min >= 1 AND contract_max >= contract_min)
33
+ );
34
+
35
+ -- Every published revision, append-only: the audit trail and the source of a rollback.
36
+ CREATE TABLE IF NOT EXISTS app_config_history (
37
+ id BIGSERIAL PRIMARY KEY,
38
+ platform VARCHAR(16) NOT NULL,
39
+ revision INTEGER NOT NULL,
40
+ min_supported_version VARCHAR(32) NOT NULL,
41
+ latest_version VARCHAR(32) NOT NULL,
42
+ update_override VARCHAR(8),
43
+ store_url TEXT,
44
+ maintenance_on BOOLEAN NOT NULL,
45
+ maintenance_message JSONB NOT NULL,
46
+ message JSONB NOT NULL,
47
+ contract_min INTEGER NOT NULL,
48
+ contract_max INTEGER NOT NULL,
49
+ contract_sunset DATE,
50
+ flags JSONB NOT NULL,
51
+ reason TEXT NOT NULL,
52
+ changed_by VARCHAR(255) NOT NULL,
53
+ changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
54
+ CONSTRAINT app_config_history_revision UNIQUE (platform, revision)
55
+ );
56
+
57
+ INSERT INTO app_config (platform)
58
+ VALUES ('ios'), ('android'), ('macos'), ('windows'), ('linux'), ('web')
59
+ ON CONFLICT (platform) DO NOTHING;
60
+
61
+ INSERT INTO app_config_history (platform, revision, min_supported_version, latest_version, update_override,
62
+ store_url, maintenance_on, maintenance_message, message, contract_min,
63
+ contract_max, contract_sunset, flags, reason, changed_by, changed_at)
64
+ SELECT platform, revision, min_supported_version, latest_version, update_override, store_url, maintenance_on,
65
+ maintenance_message, message, contract_min, contract_max, contract_sunset, flags, 'seed', updated_by,
66
+ updated_at
67
+ FROM app_config
68
+ ON CONFLICT (platform, revision) DO NOTHING;
69
+ `;
70
+
71
+ // src/server.ts
72
+ function memoryStore(records = []) {
73
+ const byPlatform = new Map(records.map((r) => [r.platform, r]));
74
+ return { get: async (platform) => byPlatform.get(platform) };
75
+ }
76
+ function cachedStore(store, { ttlMs, now = Date.now }) {
77
+ const cache = /* @__PURE__ */ new Map();
78
+ return {
79
+ evict: (platform) => platform ? cache.delete(platform) : cache.clear(),
80
+ async get(platform) {
81
+ const hit = cache.get(platform);
82
+ if (hit && now() - hit.at < ttlMs) return hit.record;
83
+ try {
84
+ const record = await store.get(platform) ?? defaultRecord(platform);
85
+ cache.set(platform, { record, at: now() });
86
+ return record;
87
+ } catch {
88
+ return hit?.record ?? defaultRecord(platform);
89
+ }
90
+ }
91
+ };
92
+ }
93
+ async function etagOf(text) {
94
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
95
+ const hex = Array.from(new Uint8Array(digest).slice(0, 12), (b) => b.toString(16).padStart(2, "0")).join("");
96
+ return `"${hex}"`;
97
+ }
98
+ function problem(status, code, detail) {
99
+ return Response.json({ title: "Bad Request", status, code, detail }, { status, headers: { "content-type": "application/problem+json" } });
100
+ }
101
+ function languageOf(header) {
102
+ const first = (header ?? "").split(",", 1).join("").trim();
103
+ return first.length >= 2 ? first.slice(0, 2) : null;
104
+ }
105
+ function createAppConfigHandler({ store, maxAgeSeconds = 60 }) {
106
+ return async (request) => {
107
+ const url = new URL(request.url);
108
+ const platform = toPlatform(url.searchParams.get("platform") ?? request.headers.get(CLIENT_HEADERS.platform));
109
+ if (!platform) return problem(400, "unknown-platform", "platform must be one of ios, android, macos, windows, linux, web.");
110
+ const versionText = url.searchParams.get("version");
111
+ const version = versionText ? parseVersion(versionText) : null;
112
+ if (versionText && !version) return problem(400, "invalid-version", "version must look like 1.4.0.");
113
+ const language = url.searchParams.get("locale") ?? languageOf(request.headers.get("accept-language"));
114
+ const record = await store.get(platform) ?? defaultRecord(platform);
115
+ const payload = buildPayload(record, version, language);
116
+ const body = JSON.stringify(payload);
117
+ const etag = await etagOf(body);
118
+ const headers = {
119
+ "cache-control": `public, max-age=${maxAgeSeconds}, stale-while-revalidate=${maxAgeSeconds * 5}`,
120
+ etag,
121
+ vary: "Accept-Language",
122
+ [CLIENT_HEADERS.contract]: String(payload.contract.max)
123
+ };
124
+ if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
125
+ return new Response(body, { status: 200, headers: { ...headers, "content-type": "application/json" } });
126
+ };
127
+ }
128
+ async function checkClientVersion(request, { store, problemBase = "https://krizaka.com/problems/" }) {
129
+ const platform = toPlatform(request.headers.get(CLIENT_HEADERS.platform));
130
+ if (!platform) return { refusal: null, headers: {}, result: null };
131
+ const record = await store.get(platform) ?? defaultRecord(platform);
132
+ const result = evaluateGuard(record, {
133
+ platform,
134
+ version: request.headers.get(CLIENT_HEADERS.version),
135
+ contract: request.headers.get(CLIENT_HEADERS.contract),
136
+ language: languageOf(request.headers.get("accept-language"))
137
+ });
138
+ const headers = { [CLIENT_HEADERS.contract]: String(result.contract) };
139
+ if (result.deprecation) headers[CLIENT_HEADERS.deprecation] = "true";
140
+ if (result.status === 200 && result.sunset) headers[CLIENT_HEADERS.sunset] = result.sunset;
141
+ if (result.status !== 426) return { refusal: null, headers, result };
142
+ const { code, payload } = result;
143
+ const detail = code === "contract-unsupported" ? "This version of the app speaks an API contract that is no longer served. Update the app." : "This version of the app is no longer supported. Update the app.";
144
+ const refusal = new Response(
145
+ JSON.stringify({ type: problemBase + code, title: "Upgrade Required", status: 426, code, detail, instance: new URL(request.url).pathname, appConfig: payload }),
146
+ {
147
+ status: 426,
148
+ headers: {
149
+ ...headers,
150
+ "content-type": "application/problem+json",
151
+ "cache-control": "no-store",
152
+ upgrade: `krizaka-app/${payload.minSupportedVersion}`
153
+ }
154
+ }
155
+ );
156
+ return { refusal, headers, result };
157
+ }
158
+ function withClientVersionGuard(options, handler) {
159
+ return async (request, ...args) => {
160
+ const outcome = await checkClientVersion(request, options);
161
+ if (outcome.refusal) return outcome.refusal;
162
+ const response = await handler(request, ...args);
163
+ for (const [name, value] of Object.entries(outcome.headers)) response.headers.set(name, value);
164
+ return response;
165
+ };
166
+ }
167
+ export {
168
+ APP_CONFIG_PATH,
169
+ cachedStore,
170
+ checkClientVersion,
171
+ createAppConfigHandler,
172
+ memoryStore,
173
+ schemaSql,
174
+ withClientVersionGuard
175
+ };
176
+ //# sourceMappingURL=server.js.map