@next-box/appsettings 0.0.0-stage → 0.0.2

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.
Files changed (260) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -2
  3. package/bin/appsettings.mjs +7 -0
  4. package/dist/cjs/cli/cli/command.cjs +2 -0
  5. package/dist/cjs/cli/cli/command.cjs.map +1 -0
  6. package/dist/cjs/cli/cli/handler.cjs +2 -0
  7. package/dist/cjs/cli/cli/handler.cjs.map +1 -0
  8. package/dist/cjs/cli/cli/index.cjs +2 -0
  9. package/dist/cjs/cli/cli/index.cjs.map +1 -0
  10. package/dist/cjs/components/AppSettingsProvider.cjs +3 -0
  11. package/dist/cjs/components/AppSettingsProvider.cjs.map +1 -0
  12. package/dist/cjs/components/Setting.cjs +3 -0
  13. package/dist/cjs/components/Setting.cjs.map +1 -0
  14. package/dist/cjs/config/AppSettingsImplementation.cjs +2 -0
  15. package/dist/cjs/config/AppSettingsImplementation.cjs.map +1 -0
  16. package/dist/cjs/config/ConfigReader.cjs +2 -0
  17. package/dist/cjs/config/ConfigReader.cjs.map +1 -0
  18. package/dist/cjs/config/ConfigStore.cjs +2 -0
  19. package/dist/cjs/config/ConfigStore.cjs.map +1 -0
  20. package/dist/cjs/config/ScopedSettingsImplementation.cjs +2 -0
  21. package/dist/cjs/config/ScopedSettingsImplementation.cjs.map +1 -0
  22. package/dist/cjs/config/helpers/freezeDeep.cjs +2 -0
  23. package/dist/cjs/config/helpers/freezeDeep.cjs.map +1 -0
  24. package/dist/cjs/hooks/useAppSetting.cjs +2 -0
  25. package/dist/cjs/hooks/useAppSetting.cjs.map +1 -0
  26. package/dist/cjs/hooks/useAppSettings.cjs +2 -0
  27. package/dist/cjs/hooks/useAppSettings.cjs.map +1 -0
  28. package/dist/cjs/hooks/useAppSettingsContext.cjs +2 -0
  29. package/dist/cjs/hooks/useAppSettingsContext.cjs.map +1 -0
  30. package/dist/cjs/index.cjs +2 -0
  31. package/dist/cjs/index.cjs.map +1 -0
  32. package/dist/cjs/server/config/ConfigMerger.cjs +2 -0
  33. package/dist/cjs/server/config/ConfigMerger.cjs.map +1 -0
  34. package/dist/cjs/server/config/ConfigValidator.cjs +2 -0
  35. package/dist/cjs/server/config/ConfigValidator.cjs.map +1 -0
  36. package/dist/cjs/server/config/createAppSettings.cjs +2 -0
  37. package/dist/cjs/server/config/createAppSettings.cjs.map +1 -0
  38. package/dist/cjs/server/server.cjs +2 -0
  39. package/dist/cjs/server/server.cjs.map +1 -0
  40. package/dist/cjs/server/sources/AppSettingsSource.cjs +2 -0
  41. package/dist/cjs/server/sources/AppSettingsSource.cjs.map +1 -0
  42. package/dist/cjs/server/sources/EnvironmentSource.cjs +2 -0
  43. package/dist/cjs/server/sources/EnvironmentSource.cjs.map +1 -0
  44. package/dist/cjs/server/sources/MemorySource.cjs +2 -0
  45. package/dist/cjs/server/sources/MemorySource.cjs.map +1 -0
  46. package/dist/cjs/server/sources/RemoteSource.cjs +2 -0
  47. package/dist/cjs/server/sources/RemoteSource.cjs.map +1 -0
  48. package/dist/cjs/server/sources/helpers/loadJson.cjs +2 -0
  49. package/dist/cjs/server/sources/helpers/loadJson.cjs.map +1 -0
  50. package/dist/esm/cli/cli/command.mjs +2 -0
  51. package/dist/esm/cli/cli/command.mjs.map +1 -0
  52. package/dist/esm/cli/cli/handler.mjs +2 -0
  53. package/dist/esm/cli/cli/handler.mjs.map +1 -0
  54. package/dist/esm/cli/cli/index.mjs +2 -0
  55. package/dist/esm/cli/cli/index.mjs.map +1 -0
  56. package/dist/esm/components/AppSettingsProvider.mjs +3 -0
  57. package/dist/esm/components/AppSettingsProvider.mjs.map +1 -0
  58. package/dist/esm/components/Setting.mjs +3 -0
  59. package/dist/esm/components/Setting.mjs.map +1 -0
  60. package/dist/esm/config/AppSettingsImplementation.mjs +2 -0
  61. package/dist/esm/config/AppSettingsImplementation.mjs.map +1 -0
  62. package/dist/esm/config/ConfigReader.mjs +2 -0
  63. package/dist/esm/config/ConfigReader.mjs.map +1 -0
  64. package/dist/esm/config/ConfigStore.mjs +2 -0
  65. package/dist/esm/config/ConfigStore.mjs.map +1 -0
  66. package/dist/esm/config/ScopedSettingsImplementation.mjs +2 -0
  67. package/dist/esm/config/ScopedSettingsImplementation.mjs.map +1 -0
  68. package/dist/esm/config/helpers/freezeDeep.mjs +2 -0
  69. package/dist/esm/config/helpers/freezeDeep.mjs.map +1 -0
  70. package/dist/esm/hooks/useAppSetting.mjs +2 -0
  71. package/dist/esm/hooks/useAppSetting.mjs.map +1 -0
  72. package/dist/esm/hooks/useAppSettings.mjs +2 -0
  73. package/dist/esm/hooks/useAppSettings.mjs.map +1 -0
  74. package/dist/esm/hooks/useAppSettingsContext.mjs +2 -0
  75. package/dist/esm/hooks/useAppSettingsContext.mjs.map +1 -0
  76. package/dist/esm/index.mjs +2 -0
  77. package/dist/esm/index.mjs.map +1 -0
  78. package/dist/esm/server/config/ConfigMerger.mjs +2 -0
  79. package/dist/esm/server/config/ConfigMerger.mjs.map +1 -0
  80. package/dist/esm/server/config/ConfigValidator.mjs +2 -0
  81. package/dist/esm/server/config/ConfigValidator.mjs.map +1 -0
  82. package/dist/esm/server/config/createAppSettings.mjs +2 -0
  83. package/dist/esm/server/config/createAppSettings.mjs.map +1 -0
  84. package/dist/esm/server/server.mjs +2 -0
  85. package/dist/esm/server/server.mjs.map +1 -0
  86. package/dist/esm/server/sources/AppSettingsSource.mjs +2 -0
  87. package/dist/esm/server/sources/AppSettingsSource.mjs.map +1 -0
  88. package/dist/esm/server/sources/EnvironmentSource.mjs +2 -0
  89. package/dist/esm/server/sources/EnvironmentSource.mjs.map +1 -0
  90. package/dist/esm/server/sources/MemorySource.mjs +2 -0
  91. package/dist/esm/server/sources/MemorySource.mjs.map +1 -0
  92. package/dist/esm/server/sources/RemoteSource.mjs +2 -0
  93. package/dist/esm/server/sources/RemoteSource.mjs.map +1 -0
  94. package/dist/esm/server/sources/helpers/loadJson.mjs +2 -0
  95. package/dist/esm/server/sources/helpers/loadJson.mjs.map +1 -0
  96. package/dist/production.analysis.txt +89 -0
  97. package/dist/types/cjs/__testUtils__/components/ErrorBoundary.d.cts +14 -0
  98. package/dist/types/cjs/__testUtils__/components/ErrorBoundary.d.cts.map +1 -0
  99. package/dist/types/cjs/cli/command.d.cts +7 -0
  100. package/dist/types/cjs/cli/command.d.cts.map +1 -0
  101. package/dist/types/cjs/cli/handler.d.cts +7 -0
  102. package/dist/types/cjs/cli/handler.d.cts.map +1 -0
  103. package/dist/types/cjs/cli/index.d.cts +2 -0
  104. package/dist/types/cjs/cli/index.d.cts.map +1 -0
  105. package/dist/types/cjs/components/AppSettingsProvider.d.cts +7 -0
  106. package/dist/types/cjs/components/AppSettingsProvider.d.cts.map +1 -0
  107. package/dist/types/cjs/components/Setting.d.cts +20 -0
  108. package/dist/types/cjs/components/Setting.d.cts.map +1 -0
  109. package/dist/types/cjs/config/AppSettingsImplementation.d.cts +15 -0
  110. package/dist/types/cjs/config/AppSettingsImplementation.d.cts.map +1 -0
  111. package/dist/types/cjs/config/ConfigMerger.d.cts +4 -0
  112. package/dist/types/cjs/config/ConfigMerger.d.cts.map +1 -0
  113. package/dist/types/cjs/config/ConfigReader.d.cts +14 -0
  114. package/dist/types/cjs/config/ConfigReader.d.cts.map +1 -0
  115. package/dist/types/cjs/config/ConfigStore.d.cts +6 -0
  116. package/dist/types/cjs/config/ConfigStore.d.cts.map +1 -0
  117. package/dist/types/cjs/config/ConfigValidator.d.cts +7 -0
  118. package/dist/types/cjs/config/ConfigValidator.d.cts.map +1 -0
  119. package/dist/types/cjs/config/ScopedSettingsImplementation.d.cts +13 -0
  120. package/dist/types/cjs/config/ScopedSettingsImplementation.d.cts.map +1 -0
  121. package/dist/types/cjs/config/createAppSettings.d.cts +3 -0
  122. package/dist/types/cjs/config/createAppSettings.d.cts.map +1 -0
  123. package/dist/types/cjs/config/helpers/freezeDeep.d.cts +2 -0
  124. package/dist/types/cjs/config/helpers/freezeDeep.d.cts.map +1 -0
  125. package/dist/types/cjs/hooks/useAppSetting.d.cts +6 -0
  126. package/dist/types/cjs/hooks/useAppSetting.d.cts.map +1 -0
  127. package/dist/types/cjs/hooks/useAppSettings.d.cts +6 -0
  128. package/dist/types/cjs/hooks/useAppSettings.d.cts.map +1 -0
  129. package/dist/types/cjs/hooks/useAppSettingsContext.d.cts +5 -0
  130. package/dist/types/cjs/hooks/useAppSettingsContext.d.cts.map +1 -0
  131. package/dist/types/cjs/index.d.cts +10 -0
  132. package/dist/types/cjs/index.d.cts.map +1 -0
  133. package/dist/types/cjs/server.d.cts +8 -0
  134. package/dist/types/cjs/server.d.cts.map +1 -0
  135. package/dist/types/cjs/sources/AppSettingsSource.d.cts +14 -0
  136. package/dist/types/cjs/sources/AppSettingsSource.d.cts.map +1 -0
  137. package/dist/types/cjs/sources/EnvironmentSource.d.cts +16 -0
  138. package/dist/types/cjs/sources/EnvironmentSource.d.cts.map +1 -0
  139. package/dist/types/cjs/sources/MemorySource.d.cts +12 -0
  140. package/dist/types/cjs/sources/MemorySource.d.cts.map +1 -0
  141. package/dist/types/cjs/sources/RemoteSource.d.cts +16 -0
  142. package/dist/types/cjs/sources/RemoteSource.d.cts.map +1 -0
  143. package/dist/types/cjs/sources/helpers/loadJson.d.cts +3 -0
  144. package/dist/types/cjs/sources/helpers/loadJson.d.cts.map +1 -0
  145. package/dist/types/cjs/types/AppSettings.types.d.cts +26 -0
  146. package/dist/types/cjs/types/AppSettings.types.d.cts.map +1 -0
  147. package/dist/types/cjs/types/Config.types.d.cts +12 -0
  148. package/dist/types/cjs/types/Config.types.d.cts.map +1 -0
  149. package/dist/types/cjs/types/Source.types.d.cts +5 -0
  150. package/dist/types/cjs/types/Source.types.d.cts.map +1 -0
  151. package/dist/types/esm/__testUtils__/components/ErrorBoundary.d.ts +14 -0
  152. package/dist/types/esm/__testUtils__/components/ErrorBoundary.d.ts.map +1 -0
  153. package/dist/types/esm/cli/command.d.ts +7 -0
  154. package/dist/types/esm/cli/command.d.ts.map +1 -0
  155. package/dist/types/esm/cli/handler.d.ts +7 -0
  156. package/dist/types/esm/cli/handler.d.ts.map +1 -0
  157. package/dist/types/esm/cli/index.d.ts +2 -0
  158. package/dist/types/esm/cli/index.d.ts.map +1 -0
  159. package/dist/types/esm/components/AppSettingsProvider.d.ts +7 -0
  160. package/dist/types/esm/components/AppSettingsProvider.d.ts.map +1 -0
  161. package/dist/types/esm/components/Setting.d.ts +20 -0
  162. package/dist/types/esm/components/Setting.d.ts.map +1 -0
  163. package/dist/types/esm/config/AppSettingsImplementation.d.ts +15 -0
  164. package/dist/types/esm/config/AppSettingsImplementation.d.ts.map +1 -0
  165. package/dist/types/esm/config/ConfigMerger.d.ts +4 -0
  166. package/dist/types/esm/config/ConfigMerger.d.ts.map +1 -0
  167. package/dist/types/esm/config/ConfigReader.d.ts +14 -0
  168. package/dist/types/esm/config/ConfigReader.d.ts.map +1 -0
  169. package/dist/types/esm/config/ConfigStore.d.ts +6 -0
  170. package/dist/types/esm/config/ConfigStore.d.ts.map +1 -0
  171. package/dist/types/esm/config/ConfigValidator.d.ts +7 -0
  172. package/dist/types/esm/config/ConfigValidator.d.ts.map +1 -0
  173. package/dist/types/esm/config/ScopedSettingsImplementation.d.ts +13 -0
  174. package/dist/types/esm/config/ScopedSettingsImplementation.d.ts.map +1 -0
  175. package/dist/types/esm/config/createAppSettings.d.ts +3 -0
  176. package/dist/types/esm/config/createAppSettings.d.ts.map +1 -0
  177. package/dist/types/esm/config/helpers/freezeDeep.d.ts +2 -0
  178. package/dist/types/esm/config/helpers/freezeDeep.d.ts.map +1 -0
  179. package/dist/types/esm/hooks/useAppSetting.d.ts +6 -0
  180. package/dist/types/esm/hooks/useAppSetting.d.ts.map +1 -0
  181. package/dist/types/esm/hooks/useAppSettings.d.ts +6 -0
  182. package/dist/types/esm/hooks/useAppSettings.d.ts.map +1 -0
  183. package/dist/types/esm/hooks/useAppSettingsContext.d.ts +5 -0
  184. package/dist/types/esm/hooks/useAppSettingsContext.d.ts.map +1 -0
  185. package/dist/types/esm/index.d.ts +10 -0
  186. package/dist/types/esm/index.d.ts.map +1 -0
  187. package/dist/types/esm/server.d.ts +8 -0
  188. package/dist/types/esm/server.d.ts.map +1 -0
  189. package/dist/types/esm/sources/AppSettingsSource.d.ts +14 -0
  190. package/dist/types/esm/sources/AppSettingsSource.d.ts.map +1 -0
  191. package/dist/types/esm/sources/EnvironmentSource.d.ts +16 -0
  192. package/dist/types/esm/sources/EnvironmentSource.d.ts.map +1 -0
  193. package/dist/types/esm/sources/MemorySource.d.ts +12 -0
  194. package/dist/types/esm/sources/MemorySource.d.ts.map +1 -0
  195. package/dist/types/esm/sources/RemoteSource.d.ts +16 -0
  196. package/dist/types/esm/sources/RemoteSource.d.ts.map +1 -0
  197. package/dist/types/esm/sources/helpers/loadJson.d.ts +3 -0
  198. package/dist/types/esm/sources/helpers/loadJson.d.ts.map +1 -0
  199. package/dist/types/esm/types/AppSettings.types.d.ts +26 -0
  200. package/dist/types/esm/types/AppSettings.types.d.ts.map +1 -0
  201. package/dist/types/esm/types/Config.types.d.ts +12 -0
  202. package/dist/types/esm/types/Config.types.d.ts.map +1 -0
  203. package/dist/types/esm/types/Source.types.d.ts +5 -0
  204. package/dist/types/esm/types/Source.types.d.ts.map +1 -0
  205. package/dist/types/tsconfig.build.tsbuildinfo +1 -0
  206. package/package.json +85 -3
  207. package/rollup.config.mjs +25 -0
  208. package/src/__testUtils__/AppSettingsSchema.d.ts +9 -0
  209. package/src/__testUtils__/components/ErrorBoundary.tsx +25 -0
  210. package/src/cli/command.test.ts +36 -0
  211. package/src/cli/command.ts +33 -0
  212. package/src/cli/handler.test.ts +61 -0
  213. package/src/cli/handler.ts +58 -0
  214. package/src/cli/index.test.ts +45 -0
  215. package/src/cli/index.ts +13 -0
  216. package/src/components/AppSettingsProvider.test.tsx +25 -0
  217. package/src/components/AppSettingsProvider.tsx +18 -0
  218. package/src/components/Setting.test.tsx +59 -0
  219. package/src/components/Setting.tsx +43 -0
  220. package/src/config/AppSettingsImplementation.test.ts +65 -0
  221. package/src/config/AppSettingsImplementation.ts +61 -0
  222. package/src/config/ConfigMerger.test.ts +44 -0
  223. package/src/config/ConfigMerger.ts +9 -0
  224. package/src/config/ConfigReader.test.ts +56 -0
  225. package/src/config/ConfigReader.ts +45 -0
  226. package/src/config/ConfigStore.test.ts +32 -0
  227. package/src/config/ConfigStore.ts +12 -0
  228. package/src/config/ConfigValidator.test.ts +30 -0
  229. package/src/config/ConfigValidator.ts +13 -0
  230. package/src/config/ScopedSettingsImplementation.test.ts +31 -0
  231. package/src/config/ScopedSettingsImplementation.ts +36 -0
  232. package/src/config/createAppSettings.test.ts +84 -0
  233. package/src/config/createAppSettings.ts +18 -0
  234. package/src/config/helpers/freezeDeep.test.ts +41 -0
  235. package/src/config/helpers/freezeDeep.ts +15 -0
  236. package/src/hooks/useAppSetting.test.tsx +37 -0
  237. package/src/hooks/useAppSetting.ts +14 -0
  238. package/src/hooks/useAppSettings.test.tsx +37 -0
  239. package/src/hooks/useAppSettings.ts +17 -0
  240. package/src/hooks/useAppSettingsContext.test.tsx +45 -0
  241. package/src/hooks/useAppSettingsContext.ts +17 -0
  242. package/src/index.ts +11 -0
  243. package/src/server.ts +7 -0
  244. package/src/sources/AppSettingsSource.test.ts +61 -0
  245. package/src/sources/AppSettingsSource.ts +30 -0
  246. package/src/sources/EnvironmentSource.test.ts +45 -0
  247. package/src/sources/EnvironmentSource.ts +87 -0
  248. package/src/sources/MemorySource.test.ts +31 -0
  249. package/src/sources/MemorySource.ts +21 -0
  250. package/src/sources/RemoteSource.test.ts +80 -0
  251. package/src/sources/RemoteSource.ts +50 -0
  252. package/src/sources/helpers/loadJson.test.ts +74 -0
  253. package/src/sources/helpers/loadJson.ts +29 -0
  254. package/src/types/AppSettings.types.test.ts +61 -0
  255. package/src/types/AppSettings.types.ts +30 -0
  256. package/src/types/Config.types.test.ts +36 -0
  257. package/src/types/Config.types.ts +27 -0
  258. package/src/types/Source.types.ts +4 -0
  259. package/tsconfig.build.json +10 -0
  260. package/tsconfig.json +10 -0
@@ -0,0 +1,45 @@
1
+ /** @jest-environment @repodog/jest-config/FailOnJsdomConsoleEnvironment.mjs */
2
+
3
+ import { describe, expect, it } from '@jest/globals';
4
+ import { render, renderHook, screen } from '@testing-library/react';
5
+ import { type ReactNode } from 'react';
6
+ import { ErrorBoundary } from '../__testUtils__/components/ErrorBoundary.tsx';
7
+ import { AppSettingsProvider } from '../components/AppSettingsProvider.tsx';
8
+ import { useAppSettingsContext } from './useAppSettingsContext.ts';
9
+
10
+ describe('useAppSettingsContext', () => {
11
+ it('When used within a provider, then it returns the settings context instance', () => {
12
+ // Arrange
13
+ const settings = { Api: { Timeout: 5000 } };
14
+
15
+ const wrapper = ({ children }: { children: ReactNode }) => (
16
+ <AppSettingsProvider settings={settings}>{children}</AppSettingsProvider>
17
+ );
18
+
19
+ // Act
20
+ const { result } = renderHook(() => useAppSettingsContext(), { wrapper });
21
+
22
+ // Assert
23
+ expect(result.current.get('Api.Timeout')).toBe(5000);
24
+ });
25
+
26
+ it('When used outside a provider, then it throws a descriptive error', () => {
27
+ // Arrange
28
+ const ContextReader = () => {
29
+ useAppSettingsContext();
30
+ // Required for React typing
31
+ // eslint-disable-next-line unicorn/no-null
32
+ return null;
33
+ };
34
+
35
+ // Act
36
+ render(
37
+ <ErrorBoundary>
38
+ <ContextReader />
39
+ </ErrorBoundary>,
40
+ );
41
+
42
+ // Assert
43
+ expect(screen.getByText('useAppSettingsContext must be used within an <AppSettingsProvider>.')).toBeTruthy();
44
+ });
45
+ });
@@ -0,0 +1,17 @@
1
+ import { createContext, use } from 'react';
2
+ import { type InternalAppSettings } from '#types/AppSettings.types.ts';
3
+ import { type AppSettingsSchema } from '../index.ts';
4
+
5
+ export const AppSettingsContext = createContext<unknown>(undefined);
6
+
7
+ export const useAppSettingsContext = <T extends object = AppSettingsSchema>(): InternalAppSettings<T> => {
8
+ const settings = use(AppSettingsContext);
9
+
10
+ if (!settings || typeof settings !== 'object') {
11
+ throw new Error('useAppSettingsContext must be used within an <AppSettingsProvider>.');
12
+ }
13
+
14
+ // Context is untyped because each provider supplies a different schema.
15
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
16
+ return settings as InternalAppSettings<T>;
17
+ };
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { AppSettingsProvider } from '#components/AppSettingsProvider.tsx';
2
+ export { Setting } from '#components/Setting.tsx';
3
+ export { useAppSetting } from '#hooks/useAppSetting.ts';
4
+ export { useAppSettings } from '#hooks/useAppSettings.ts';
5
+ export type { AppSettings, ScopedSettings } from '#types/AppSettings.types.ts';
6
+ export type { SettingPath, SettingSectionPath, SettingValue } from '#types/Config.types.ts';
7
+
8
+ // Consumers can augment this interface through the package's public entry point.
9
+ export interface AppSettingsSchema {
10
+ readonly __appSettingsSchema?: never;
11
+ }
package/src/server.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { createAppSettings } from '#config/createAppSettings.ts';
2
+ export { AppSettingsSource, type AppSettingsSourceOptions } from '#sources/AppSettingsSource.ts';
3
+ export { EnvironmentSource, type EnvironmentSourceOptions } from '#sources/EnvironmentSource.ts';
4
+ export { RemoteSource, type RemoteSourceOptions } from '#sources/RemoteSource.ts';
5
+ export { MemorySource, type MemorySourceOptions } from '#sources/MemorySource.ts';
6
+ export { type CreateAppSettingsOptions } from '#types/AppSettings.types.ts';
7
+ export type { SettingsSource } from '#types/Source.types.ts';
@@ -0,0 +1,61 @@
1
+ /** @jest-environment node */
2
+
3
+ import { afterEach, describe, expect, it } from '@jest/globals';
4
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { AppSettingsSource } from './AppSettingsSource.ts';
8
+
9
+ describe('AppSettingsSource', () => {
10
+ let directory: string | undefined;
11
+
12
+ afterEach(async () => {
13
+ if (!directory) {
14
+ return;
15
+ }
16
+
17
+ await rm(directory, { force: true, recursive: true });
18
+ directory = undefined;
19
+ });
20
+
21
+ it('When base and environment files exist, then the environment file overrides the base recursively', async () => {
22
+ // Arrange
23
+ directory = await mkdtemp(join(tmpdir(), 'appsettings-source-'));
24
+ await writeFile(join(directory, 'appsettings.json'), JSON.stringify({ Api: { Retry: 3, Timeout: 1000 } }));
25
+ await writeFile(join(directory, 'appsettings.test.json'), JSON.stringify({ Api: { Timeout: 5000 } }));
26
+ const source = new AppSettingsSource({ directory, environment: 'test' });
27
+
28
+ // Act
29
+ const result = await source.load();
30
+
31
+ // Assert
32
+ expect(result).toEqual({ Api: { Retry: 3, Timeout: 5000 } });
33
+ expect(source.name).toBe('appsettings:test');
34
+ });
35
+
36
+ it('When the environment file is absent, then it returns the base file', async () => {
37
+ // Arrange
38
+ directory = await mkdtemp(join(tmpdir(), 'appsettings-source-'));
39
+ await writeFile(join(directory, 'appsettings.json'), JSON.stringify({ App: { Name: 'Example' } }));
40
+ const source = new AppSettingsSource({ directory, environment: 'test', name: 'custom' });
41
+
42
+ // Act
43
+ const result = await source.load();
44
+
45
+ // Assert
46
+ expect(result).toEqual({ App: { Name: 'Example' } });
47
+ expect(source.name).toBe('custom');
48
+ });
49
+
50
+ it('When the base file is absent, then loading rejects', async () => {
51
+ // Arrange
52
+ directory = await mkdtemp(join(tmpdir(), 'appsettings-source-'));
53
+ const source = new AppSettingsSource({ directory, environment: 'test' });
54
+
55
+ // Act
56
+ const result = source.load();
57
+
58
+ // Assert
59
+ await expect(result).rejects.toMatchObject({ code: 'ENOENT' });
60
+ });
61
+ });
@@ -0,0 +1,30 @@
1
+ import { resolve } from 'node:path';
2
+ import { ConfigMerger } from '#config/ConfigMerger.ts';
3
+ import { loadJson } from '#sources/helpers/loadJson.ts';
4
+ import { type SettingsSource } from '../types/Source.types.ts';
5
+
6
+ export interface AppSettingsSourceOptions {
7
+ directory?: string;
8
+ environment?: string;
9
+ name?: string;
10
+ }
11
+
12
+ export class AppSettingsSource implements SettingsSource {
13
+ private directory: string;
14
+ private environment: string;
15
+ public name: string;
16
+
17
+ public constructor(options: AppSettingsSourceOptions) {
18
+ this.directory = options.directory ?? process.cwd();
19
+ this.environment = options.environment ?? process.env.NODE_ENV ?? 'development';
20
+ this.name = options.name ?? `appsettings:${this.environment}`;
21
+ }
22
+
23
+ async load(): Promise<Record<string, unknown>> {
24
+ const basePath = resolve(this.directory, 'appsettings.json');
25
+ const environmentPath = resolve(this.directory, `appsettings.${this.environment}.json`);
26
+ const base = await loadJson(basePath, false);
27
+ const override = (await loadJson(environmentPath, true)) ?? {};
28
+ return new ConfigMerger().merge(base, override);
29
+ }
30
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { EnvironmentSource } from './EnvironmentSource.ts';
3
+
4
+ describe('EnvironmentSource', () => {
5
+ it('When matching variables are loaded, then it maps nested keys and parses booleans and numbers', async () => {
6
+ // Arrange
7
+ const source = new EnvironmentSource({
8
+ envs: {
9
+ APP_API__BASE_URL: 'https://api.example.com',
10
+ APP_API__TIMEOUT: '5000',
11
+ APP_FEATURES__DARK_MODE: 'true',
12
+ APP_FEATURES__NEW_DASHBOARD: 'false',
13
+ APP_UNSET: undefined,
14
+ OTHER_API__TIMEOUT: '1000',
15
+ },
16
+ });
17
+
18
+ // Act
19
+ const result = await source.load();
20
+
21
+ // Assert
22
+ expect(result).toEqual({
23
+ Api: { BaseUrl: 'https://api.example.com', Timeout: 5000 },
24
+ Features: { DarkMode: true, NewDashboard: false },
25
+ });
26
+
27
+ expect(source.name).toBe('environment:APP_');
28
+ });
29
+
30
+ it('When a custom prefix and name are provided, then it uses them and preserves non-numeric values', async () => {
31
+ // Arrange
32
+ const source = new EnvironmentSource({
33
+ envs: { SERVICE_MODE: 'Development', SERVICE_PORT: 'not-a-number' },
34
+ name: 'service-env',
35
+ prefix: 'SERVICE_',
36
+ });
37
+
38
+ // Act
39
+ const result = await source.load();
40
+
41
+ // Assert
42
+ expect(result).toEqual({ Mode: 'Development', Port: 'not-a-number' });
43
+ expect(source.name).toBe('service-env');
44
+ });
45
+ });
@@ -0,0 +1,87 @@
1
+ import { capitalize, isPlainObject } from 'remeda';
2
+ import { type SettingsSource } from '../types/Source.types.ts';
3
+
4
+ export interface EnvironmentSourceOptions {
5
+ envs: Record<string, string | undefined>;
6
+ name?: string;
7
+ prefix?: string;
8
+ }
9
+
10
+ export class EnvironmentSource implements SettingsSource {
11
+ private readonly envs: Record<string, string | undefined>;
12
+ private readonly prefix: string;
13
+
14
+ private normalizeSegment = (value: string): string =>
15
+ value
16
+ .toLowerCase()
17
+ .split('_')
18
+ .filter(Boolean)
19
+ .map(part => capitalize(part))
20
+ .join('');
21
+
22
+ public readonly name: string;
23
+
24
+ public constructor(options: EnvironmentSourceOptions) {
25
+ this.envs = options.envs;
26
+ this.prefix = options.prefix ?? 'APP_';
27
+ this.name = options.name ?? `environment:${this.prefix}`;
28
+ }
29
+
30
+ private parseEnvironmentValue(value: string): unknown {
31
+ if (value === 'true') {
32
+ return true;
33
+ }
34
+
35
+ if (value === 'false') {
36
+ return false;
37
+ }
38
+
39
+ if (value.trim() !== '' && Number.isFinite(Number(value))) {
40
+ return Number(value);
41
+ }
42
+
43
+ return value;
44
+ }
45
+
46
+ load(): Promise<Record<string, unknown>> {
47
+ const result: Record<string, unknown> = {};
48
+
49
+ for (const [key, value] of Object.entries(this.envs)) {
50
+ if (value === undefined || !key.startsWith(this.prefix)) {
51
+ continue;
52
+ }
53
+
54
+ const path = key
55
+ .slice(this.prefix.length)
56
+ .split('__')
57
+ .filter(Boolean)
58
+ .map(part => this.normalizeSegment(part));
59
+
60
+ const leaf = path.pop();
61
+
62
+ if (!leaf) {
63
+ continue;
64
+ }
65
+
66
+ let current = result;
67
+
68
+ for (const part of path) {
69
+ const next = current[part];
70
+
71
+ if (!isPlainObject(next)) {
72
+ current[part] = {};
73
+ }
74
+
75
+ const currentPart = current[part];
76
+
77
+ if (isPlainObject(currentPart)) {
78
+ current = currentPart;
79
+ }
80
+ }
81
+
82
+ current[leaf] = this.parseEnvironmentValue(value);
83
+ }
84
+
85
+ return Promise.resolve(result);
86
+ }
87
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { MemorySource } from './MemorySource.ts';
3
+
4
+ describe('MemorySource', () => {
5
+ it('When loaded, then it returns a copy of the supplied values', async () => {
6
+ // Arrange
7
+ const values = { Api: { Timeout: 5000 } };
8
+ const source = new MemorySource({ values });
9
+
10
+ // Act
11
+ const result = await source.load();
12
+ result.Additional = true;
13
+
14
+ // Assert
15
+ expect(result).not.toBe(values);
16
+ expect(result.Api).not.toBe(values.Api);
17
+ expect(values.Api.Timeout).toBe(5000);
18
+ expect(source.name).toBe('memory');
19
+ });
20
+
21
+ it('When a custom name is provided, then it exposes that name', () => {
22
+ // Arrange
23
+ const source = new MemorySource({ name: 'runtime-overrides', values: {} });
24
+
25
+ // Act
26
+ const name = source.name;
27
+
28
+ // Assert
29
+ expect(name).toBe('runtime-overrides');
30
+ });
31
+ });
@@ -0,0 +1,21 @@
1
+ import { clone } from 'remeda';
2
+ import { type SettingsSource } from '../types/Source.types.ts';
3
+
4
+ export interface MemorySourceOptions {
5
+ name?: string;
6
+ values: Record<string, unknown>;
7
+ }
8
+
9
+ export class MemorySource implements SettingsSource {
10
+ private readonly values: Record<string, unknown>;
11
+ public readonly name: string;
12
+
13
+ public constructor(options: MemorySourceOptions) {
14
+ this.name = options.name ?? 'memory';
15
+ this.values = options.values;
16
+ }
17
+
18
+ load(): Promise<Record<string, unknown>> {
19
+ return Promise.resolve(clone(this.values));
20
+ }
21
+ }
@@ -0,0 +1,80 @@
1
+ /** @jest-environment node */
2
+
3
+ import { describe, expect, it } from '@jest/globals';
4
+ import { RemoteSource } from './RemoteSource.ts';
5
+
6
+ describe('RemoteSource', () => {
7
+ it('When the response is successful, then it returns its JSON object', async () => {
8
+ // Arrange
9
+ const response = new Response(undefined, { status: 200 });
10
+ response.json = () => Promise.resolve({ Api: { Timeout: 5000 } });
11
+ const fetch = () => Promise.resolve(response);
12
+ const source = new RemoteSource({ fetch, url: 'https://config.example.com/settings.json' });
13
+
14
+ // Act
15
+ const result = await source.load();
16
+
17
+ // Assert
18
+ expect(result).toEqual({ Api: { Timeout: 5000 } });
19
+ expect(source.name).toBe('remote:https://config.example.com/settings.json');
20
+ });
21
+
22
+ it('When a response path is provided, then it returns the selected nested object', async () => {
23
+ // Arrange
24
+ const response = new Response();
25
+ response.json = () => Promise.resolve({ data: { settings: { Api: { Timeout: 5000 } } } });
26
+ const fetch = () => Promise.resolve(response);
27
+ const source = new RemoteSource({ fetch, responsePath: 'data.settings', url: 'https://config.example.com' });
28
+
29
+ // Act
30
+ const result = await source.load();
31
+
32
+ // Assert
33
+ expect(result).toEqual({ Api: { Timeout: 5000 } });
34
+ });
35
+
36
+ it('When the response is not successful, then it throws an error containing the response status', async () => {
37
+ // Arrange
38
+ const fetch = () => Promise.resolve(new Response('Unavailable', { status: 503, statusText: 'Unavailable' }));
39
+ const source = new RemoteSource({ fetch, url: 'https://config.example.com' });
40
+
41
+ // Act
42
+ const result = source.load();
43
+
44
+ // Assert
45
+ await expect(result).rejects.toThrow('503 Unavailable');
46
+ });
47
+
48
+ it('When the JSON root or selected response path is not an object, then it rejects', async () => {
49
+ // Arrange
50
+ const fetchArray = (): Promise<Response> => Promise.resolve(new Response('[]'));
51
+ const fetchMissingPath = (): Promise<Response> => Promise.resolve(new Response('{"data": {"settings": []}}'));
52
+ const rootSource = new RemoteSource({ fetch: fetchArray, url: 'https://config.example.com' });
53
+
54
+ const nestedSource = new RemoteSource({
55
+ fetch: fetchMissingPath,
56
+ responsePath: 'data.settings',
57
+ url: 'https://config.example.com',
58
+ });
59
+
60
+ // Act
61
+ const root = rootSource.load();
62
+ const nested = nestedSource.load();
63
+
64
+ // Assert
65
+ await expect(root).rejects.toThrow('must contain an object at the root');
66
+ await expect(nested).rejects.toThrow('must contain an object at the root');
67
+ });
68
+
69
+ it('When a response path is used on a primitive JSON value, then it rejects before selecting the path', async () => {
70
+ // Arrange
71
+ const fetch = () => Promise.resolve(new Response('42'));
72
+ const source = new RemoteSource({ fetch, responsePath: 'data', url: 'https://config.example.com' });
73
+
74
+ // Act
75
+ const result = source.load();
76
+
77
+ // Assert
78
+ await expect(result).rejects.toThrow('must be an object type if response path is provided');
79
+ });
80
+ });
@@ -0,0 +1,50 @@
1
+ import { isObjectType, isPlainObject, prop, stringToPath } from 'remeda';
2
+ import { type SettingsSource } from '#types/Source.types.ts';
3
+
4
+ export interface RemoteSourceOptions {
5
+ fetch: typeof globalThis.fetch;
6
+ name?: string;
7
+ responsePath?: string;
8
+ url: string;
9
+ }
10
+
11
+ export class RemoteSource implements SettingsSource {
12
+ private fetch: typeof globalThis.fetch;
13
+ private responsePath?: string;
14
+ private url: string;
15
+ public name: string;
16
+
17
+ public constructor(options: RemoteSourceOptions) {
18
+ this.fetch = options.fetch;
19
+ this.url = options.url;
20
+ this.name = options.name ?? `remote:${this.url}`;
21
+ this.responsePath = options.responsePath;
22
+ }
23
+
24
+ async load(): Promise<Record<string, unknown>> {
25
+ if (typeof this.fetch !== 'function') {
26
+ throw new TypeError('RemoteSource requires fetch, which is unavailable in this runtime.');
27
+ }
28
+
29
+ const response = await this.fetch(this.url);
30
+
31
+ if (!response.ok) {
32
+ throw new Error(`Failed to load settings from "${this.url}": ${String(response.status)} ${response.statusText}`);
33
+ }
34
+
35
+ let value: unknown = await response.json();
36
+
37
+ if (this.responsePath && !isObjectType(value)) {
38
+ throw new Error(`Fetched value must be an object type if response path is provided, but was ${typeof value}.`);
39
+ }
40
+
41
+ // @ts-expect-error Unable to statically type string literal
42
+ value = this.responsePath ? prop(value, ...stringToPath(this.responsePath)) : value;
43
+
44
+ if (!isPlainObject(value)) {
45
+ throw new Error(`JSON settings from "${this.url}" must contain an object at the root.`);
46
+ }
47
+
48
+ return value;
49
+ }
50
+ }
@@ -0,0 +1,74 @@
1
+ /** @jest-environment node */
2
+
3
+ import { afterEach, describe, expect, it } from '@jest/globals';
4
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { loadJson } from './loadJson.ts';
8
+
9
+ describe('loadJson', () => {
10
+ let directory: string | undefined;
11
+
12
+ afterEach(async () => {
13
+ if (!directory) {
14
+ return;
15
+ }
16
+
17
+ await rm(directory, { force: true, recursive: true });
18
+ directory = undefined;
19
+ });
20
+
21
+ it('When the file contains a JSON object, then it returns the parsed object', async () => {
22
+ // Arrange
23
+ directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
24
+ const path = join(directory, 'settings.json');
25
+ await writeFile(path, JSON.stringify({ Api: { Timeout: 5000 } }));
26
+
27
+ // Act
28
+ const result = await loadJson(path, false);
29
+
30
+ // Assert
31
+ expect(result).toEqual({ Api: { Timeout: 5000 } });
32
+ });
33
+
34
+ it('When an optional file does not exist, then it returns undefined', async () => {
35
+ // Arrange
36
+ directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
37
+ const path = join(directory, 'missing.json');
38
+
39
+ // Act
40
+ const result = await loadJson(path, true);
41
+
42
+ // Assert
43
+ expect(result).toBeUndefined();
44
+ });
45
+
46
+ it('When a required file does not exist, then it propagates the filesystem error', async () => {
47
+ // Arrange
48
+ directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
49
+ const path = join(directory, 'missing.json');
50
+
51
+ // Act
52
+ const result = loadJson(path, false);
53
+
54
+ // Assert
55
+ await expect(result).rejects.toMatchObject({ code: 'ENOENT' });
56
+ });
57
+
58
+ it('When the file contains malformed JSON or a non-object root, then it rejects', async () => {
59
+ // Arrange
60
+ directory = await mkdtemp(join(tmpdir(), 'appsettings-json-'));
61
+ const malformedPath = join(directory, 'malformed.json');
62
+ const arrayPath = join(directory, 'array.json');
63
+ await writeFile(malformedPath, '{');
64
+ await writeFile(arrayPath, '[]');
65
+
66
+ // Act
67
+ const malformed = loadJson(malformedPath, false);
68
+ const array = loadJson(arrayPath, false);
69
+
70
+ // Assert
71
+ await expect(malformed).rejects.toBeInstanceOf(SyntaxError);
72
+ await expect(array).rejects.toThrow('must contain an object at the root');
73
+ });
74
+ });
@@ -0,0 +1,29 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { isPlainObject } from 'remeda';
3
+
4
+ export function loadJson(path: string, isOptional: false): Promise<Record<string, unknown>>;
5
+ export function loadJson(path: string, isOptional: true): Promise<Record<string, unknown> | undefined>;
6
+
7
+ // Required for overloads
8
+ // eslint-disable-next-line prefer-arrow/prefer-arrow-functions
9
+ export async function loadJson(path: string, isOptional: boolean): Promise<Record<string, unknown> | undefined> {
10
+ let contents: string;
11
+
12
+ try {
13
+ contents = await readFile(path, 'utf8');
14
+ } catch (error) {
15
+ if (isOptional && typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
16
+ return undefined;
17
+ }
18
+
19
+ throw error;
20
+ }
21
+
22
+ const value: unknown = JSON.parse(contents);
23
+
24
+ if (!isPlainObject(value)) {
25
+ throw new Error(`JSON settings from "${path}" must contain an object at the root.`);
26
+ }
27
+
28
+ return value;
29
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { z } from 'zod';
3
+ import { AppSettingsImplementation } from '../config/AppSettingsImplementation.ts';
4
+ import { createAppSettings } from '../config/createAppSettings.ts';
5
+ import { type AppSettings, type CreateAppSettingsOptions, type ScopedSettings } from './AppSettings.types.ts';
6
+ import { type SettingsSource } from './Source.types.ts';
7
+
8
+ type ExampleSettings = { Api: { Retry?: number; Timeout: number } };
9
+
10
+ const assertType = <Expected>(_value: Expected): void => undefined;
11
+
12
+ describe('AppSettings types', () => {
13
+ it('When configuration is loaded, then settings and scoped readers expose correctly typed values', async () => {
14
+ // Arrange
15
+ const source: SettingsSource = {
16
+ load: () => Promise.resolve({ Api: { Timeout: 5000 } }),
17
+ name: 'example',
18
+ };
19
+
20
+ const options: CreateAppSettingsOptions<ExampleSettings> = {
21
+ defaults: { Api: { Timeout: 1000 } },
22
+ schema: z.object({ Api: z.object({ Retry: z.number().optional(), Timeout: z.number() }) }),
23
+ sources: [source],
24
+ };
25
+
26
+ // Act
27
+ const config = await createAppSettings(options);
28
+ const settings: AppSettings<ExampleSettings> = new AppSettingsImplementation(config);
29
+ const scoped: ScopedSettings<{ Retry?: number; Timeout: number }> = settings.scope('Api');
30
+ const timeout = settings.get('Api.Timeout');
31
+ const retry = settings.get('Api.Retry');
32
+ const requiredTimeout = settings.getOrThrow('Api.Timeout');
33
+ const all = settings.getAll();
34
+ const section = settings.getSection('Api');
35
+ const hasTimeout = settings.has('Api.Timeout');
36
+ const scopedTimeout = scoped.get('Timeout');
37
+ const scopedRequiredTimeout = scoped.getOrThrow('Timeout');
38
+ const scopedAll = scoped.getAll();
39
+
40
+ assertType<number | undefined>(timeout);
41
+ assertType<number | undefined>(retry);
42
+ assertType<number>(requiredTimeout);
43
+ assertType<Readonly<ExampleSettings>>(all);
44
+ assertType<ExampleSettings['Api'] | undefined>(section);
45
+ assertType<boolean>(hasTimeout);
46
+ assertType<number | undefined>(scopedTimeout);
47
+ assertType<number>(scopedRequiredTimeout);
48
+ assertType<Readonly<ExampleSettings['Api']>>(scopedAll);
49
+
50
+ // Assert
51
+ expect(timeout).toBe(5000);
52
+ expect(retry).toBeUndefined();
53
+ expect(requiredTimeout).toBe(5000);
54
+ expect(all).toEqual({ Api: { Timeout: 5000 } });
55
+ expect(section).toEqual({ Timeout: 5000 });
56
+ expect(hasTimeout).toBe(true);
57
+ expect(scopedTimeout).toBe(5000);
58
+ expect(scopedRequiredTimeout).toBe(5000);
59
+ expect(scopedAll).toEqual({ Timeout: 5000 });
60
+ });
61
+ });