@krizaka/app-config 0.1.0 → 0.2.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.d.ts CHANGED
@@ -27,8 +27,9 @@ interface DismissStorage {
27
27
  setItem(key: string, value: string): void | Promise<void>;
28
28
  }
29
29
  interface AppConfigClientOptions {
30
- /** The backend's origin, e.g. `https://dev.orazaka.com`. */
31
- baseUrl: string;
30
+ /** The backend's origin, e.g. `https://dev.orazaka.com`. A function is read at each check, so an address found by
31
+ * `@krizaka/discovery` (and moved by its manifest) is followed without rebuilding the client. */
32
+ baseUrl: string | (() => string);
32
33
  platform: Platform;
33
34
  /** The version the app runs (`1.4.0`), from the build, never typed twice. */
34
35
  version: string;
package/dist/client.js CHANGED
@@ -56,7 +56,7 @@ function withDefaults(given, defaults) {
56
56
  return own;
57
57
  }
58
58
  function createAppConfigClient(options) {
59
- const base = withoutTrailingSlashes(options.baseUrl);
59
+ const base = () => withoutTrailingSlashes(typeof options.baseUrl === "function" ? options.baseUrl() : options.baseUrl);
60
60
  const doFetch = options.fetch ?? ((...args) => fetch(...args));
61
61
  const now = options.now ?? Date.now;
62
62
  const interval = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
@@ -86,7 +86,7 @@ function createAppConfigClient(options) {
86
86
  const query = new URLSearchParams({ platform: options.platform, version: options.version });
87
87
  const language = options.language?.();
88
88
  if (language) query.set("locale", language);
89
- const response = await doFetch(`${base}${APP_CONFIG_PATH}?${query}`, { headers });
89
+ const response = await doFetch(`${base()}${APP_CONFIG_PATH}?${query}`, { headers });
90
90
  const body = response.ok ? await response.json() : null;
91
91
  if (!isAppConfigPayload(body)) {
92
92
  set({ status: "failed" });
@@ -1 +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":[]}
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`. A function is read at each check, so an address found by\n * `@krizaka/discovery` (and moved by its manifest) is followed without rebuilding the client. */\n baseUrl: string | (() => 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(typeof options.baseUrl === \"function\" ? options.baseUrl() : 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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/app-config",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "App version compatibility for Krizaka apps and backends: one contract (min/latest version, update mode, maintenance, contract range, 426 Upgrade Required, N-1 with Deprecation/Sunset) for Next.js backends and mobile, desktop and web clients. Twin of the Java building block krizaka-app-config. Zero dependency.",
5
5
  "keywords": [
6
6
  "krizaka",