@global-cache/core 0.2.3

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 (162) hide show
  1. package/README.md +5 -0
  2. package/dist/client/api.d.ts +21 -0
  3. package/dist/client/api.d.ts.map +1 -0
  4. package/dist/client/api.js +43 -0
  5. package/dist/client/api.js.map +1 -0
  6. package/dist/client/index.d.ts +27 -0
  7. package/dist/client/index.d.ts.map +1 -0
  8. package/dist/client/index.js +135 -0
  9. package/dist/client/index.js.map +1 -0
  10. package/dist/client/types.d.ts +12 -0
  11. package/dist/client/types.d.ts.map +1 -0
  12. package/dist/client/types.js +3 -0
  13. package/dist/client/types.js.map +1 -0
  14. package/dist/client/utils/http-error.d.ts +3 -0
  15. package/dist/client/utils/http-error.d.ts.map +1 -0
  16. package/dist/client/utils/http-error.js +13 -0
  17. package/dist/client/utils/http-error.js.map +1 -0
  18. package/dist/client/utils/http-json.d.ts +14 -0
  19. package/dist/client/utils/http-json.d.ts.map +1 -0
  20. package/dist/client/utils/http-json.js +29 -0
  21. package/dist/client/utils/http-json.js.map +1 -0
  22. package/dist/client/utils/http-query.d.ts +9 -0
  23. package/dist/client/utils/http-query.d.ts.map +1 -0
  24. package/dist/client/utils/http-query.js +25 -0
  25. package/dist/client/utils/http-query.js.map +1 -0
  26. package/dist/client/utils/preview-value.d.ts +2 -0
  27. package/dist/client/utils/preview-value.d.ts.map +1 -0
  28. package/dist/client/utils/preview-value.js +16 -0
  29. package/dist/client/utils/preview-value.js.map +1 -0
  30. package/dist/config.d.ts +28 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +57 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +8 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/server/config.d.ts +15 -0
  39. package/dist/server/config.d.ts.map +1 -0
  40. package/dist/server/config.js +18 -0
  41. package/dist/server/config.js.map +1 -0
  42. package/dist/server/error.d.ts +3 -0
  43. package/dist/server/error.d.ts.map +1 -0
  44. package/dist/server/error.js +9 -0
  45. package/dist/server/error.js.map +1 -0
  46. package/dist/server/getter.d.ts +35 -0
  47. package/dist/server/getter.d.ts.map +1 -0
  48. package/dist/server/getter.js +109 -0
  49. package/dist/server/getter.js.map +1 -0
  50. package/dist/server/index.d.ts +14 -0
  51. package/dist/server/index.d.ts.map +1 -0
  52. package/dist/server/index.js +73 -0
  53. package/dist/server/index.js.map +1 -0
  54. package/dist/server/routes/clear.d.ts +2 -0
  55. package/dist/server/routes/clear.d.ts.map +1 -0
  56. package/dist/server/routes/clear.js +15 -0
  57. package/dist/server/routes/clear.js.map +1 -0
  58. package/dist/server/routes/get-stale-list.d.ts +7 -0
  59. package/dist/server/routes/get-stale-list.d.ts.map +1 -0
  60. package/dist/server/routes/get-stale-list.js +20 -0
  61. package/dist/server/routes/get-stale-list.js.map +1 -0
  62. package/dist/server/routes/get-stale.d.ts +7 -0
  63. package/dist/server/routes/get-stale.d.ts.map +1 -0
  64. package/dist/server/routes/get-stale.js +20 -0
  65. package/dist/server/routes/get-stale.js.map +1 -0
  66. package/dist/server/routes/get.d.ts +9 -0
  67. package/dist/server/routes/get.d.ts.map +1 -0
  68. package/dist/server/routes/get.js +23 -0
  69. package/dist/server/routes/get.js.map +1 -0
  70. package/dist/server/routes/set.d.ts +9 -0
  71. package/dist/server/routes/set.d.ts.map +1 -0
  72. package/dist/server/routes/set.js +18 -0
  73. package/dist/server/routes/set.js.map +1 -0
  74. package/dist/server/setter.d.ts +12 -0
  75. package/dist/server/setter.d.ts.map +1 -0
  76. package/dist/server/setter.js +37 -0
  77. package/dist/server/setter.js.map +1 -0
  78. package/dist/server/storage/index.d.ts +6 -0
  79. package/dist/server/storage/index.d.ts.map +1 -0
  80. package/dist/server/storage/index.js +14 -0
  81. package/dist/server/storage/index.js.map +1 -0
  82. package/dist/server/storage/multi-instance/index.d.ts +9 -0
  83. package/dist/server/storage/multi-instance/index.d.ts.map +1 -0
  84. package/dist/server/storage/multi-instance/index.js +10 -0
  85. package/dist/server/storage/multi-instance/index.js.map +1 -0
  86. package/dist/server/storage/single-instance/fs.d.ts +16 -0
  87. package/dist/server/storage/single-instance/fs.d.ts.map +1 -0
  88. package/dist/server/storage/single-instance/fs.js +59 -0
  89. package/dist/server/storage/single-instance/fs.js.map +1 -0
  90. package/dist/server/storage/single-instance/index.d.ts +11 -0
  91. package/dist/server/storage/single-instance/index.d.ts.map +1 -0
  92. package/dist/server/storage/single-instance/index.js +33 -0
  93. package/dist/server/storage/single-instance/index.js.map +1 -0
  94. package/dist/server/storage/single-instance/memory.d.ts +15 -0
  95. package/dist/server/storage/single-instance/memory.d.ts.map +1 -0
  96. package/dist/server/storage/single-instance/memory.js +41 -0
  97. package/dist/server/storage/single-instance/memory.js.map +1 -0
  98. package/dist/server/storage/types.d.ts +14 -0
  99. package/dist/server/storage/types.d.ts.map +1 -0
  100. package/dist/server/storage/types.js +3 -0
  101. package/dist/server/storage/types.js.map +1 -0
  102. package/dist/server/utils/express.d.ts +8 -0
  103. package/dist/server/utils/express.d.ts.map +1 -0
  104. package/dist/server/utils/express.js +15 -0
  105. package/dist/server/utils/express.js.map +1 -0
  106. package/dist/shared/debug.d.ts +4 -0
  107. package/dist/shared/debug.d.ts.map +1 -0
  108. package/dist/shared/debug.js +14 -0
  109. package/dist/shared/debug.js.map +1 -0
  110. package/dist/shared/logger.d.ts +2 -0
  111. package/dist/shared/logger.d.ts.map +1 -0
  112. package/dist/shared/logger.js +5 -0
  113. package/dist/shared/logger.js.map +1 -0
  114. package/dist/shared/sig.d.ts +23 -0
  115. package/dist/shared/sig.d.ts.map +1 -0
  116. package/dist/shared/sig.js +53 -0
  117. package/dist/shared/sig.js.map +1 -0
  118. package/dist/shared/ttl.d.ts +8 -0
  119. package/dist/shared/ttl.d.ts.map +1 -0
  120. package/dist/shared/ttl.js +25 -0
  121. package/dist/shared/ttl.js.map +1 -0
  122. package/dist/shared/utils/object.d.ts +2 -0
  123. package/dist/shared/utils/object.d.ts.map +1 -0
  124. package/dist/shared/utils/object.js +9 -0
  125. package/dist/shared/utils/object.js.map +1 -0
  126. package/dist/shared/value-info.d.ts +18 -0
  127. package/dist/shared/value-info.d.ts.map +1 -0
  128. package/dist/shared/value-info.js +32 -0
  129. package/dist/shared/value-info.js.map +1 -0
  130. package/package.json +41 -0
  131. package/src/client/api.ts +51 -0
  132. package/src/client/index.ts +137 -0
  133. package/src/client/types.ts +14 -0
  134. package/src/client/utils/http-error.ts +8 -0
  135. package/src/client/utils/http-json.ts +32 -0
  136. package/src/client/utils/http-query.ts +24 -0
  137. package/src/client/utils/preview-value.ts +10 -0
  138. package/src/config.ts +79 -0
  139. package/src/index.ts +2 -0
  140. package/src/server/config.ts +29 -0
  141. package/src/server/error.ts +7 -0
  142. package/src/server/getter.ts +138 -0
  143. package/src/server/index.ts +81 -0
  144. package/src/server/routes/clear.ts +15 -0
  145. package/src/server/routes/get-stale-list.ts +27 -0
  146. package/src/server/routes/get-stale.ts +27 -0
  147. package/src/server/routes/get.ts +31 -0
  148. package/src/server/routes/set.ts +27 -0
  149. package/src/server/setter.ts +35 -0
  150. package/src/server/storage/index.ts +16 -0
  151. package/src/server/storage/multi-instance/index.ts +7 -0
  152. package/src/server/storage/single-instance/fs.ts +64 -0
  153. package/src/server/storage/single-instance/index.ts +32 -0
  154. package/src/server/storage/single-instance/memory.ts +43 -0
  155. package/src/server/storage/types.ts +13 -0
  156. package/src/server/utils/express.ts +14 -0
  157. package/src/shared/debug.ts +7 -0
  158. package/src/shared/logger.ts +1 -0
  159. package/src/shared/sig.ts +84 -0
  160. package/src/shared/ttl.ts +19 -0
  161. package/src/shared/utils/object.ts +4 -0
  162. package/src/shared/value-info.ts +40 -0
@@ -0,0 +1,137 @@
1
+ import { globalConfig, GlobalCacheConfig } from '../config';
2
+ import { debug, debugKey } from '../shared/debug';
3
+ import { logger } from '../shared/logger';
4
+ import { calcSignature } from '../shared/sig';
5
+ import { StorageApi } from './api';
6
+ import { DefaultKeysSchema, GetArgs, StringKeys } from './types';
7
+ import { previewValue } from './utils/preview-value';
8
+
9
+ export { DefaultKeysSchema };
10
+
11
+ export class GlobalCacheClient<S extends DefaultKeysSchema = DefaultKeysSchema> {
12
+ #api?: StorageApi;
13
+
14
+ /*
15
+ * Helper method to set global config via storage instance (for conveniency)
16
+ */
17
+ config(config: GlobalCacheConfig) {
18
+ globalConfig.update(config);
19
+ }
20
+
21
+ private get api() {
22
+ if (!this.#api) {
23
+ const { serverUrl, runId } = globalConfig;
24
+ if (!serverUrl) {
25
+ throw new Error('Global-cache url is empty. Did you run the global-cache setup?');
26
+ }
27
+ this.#api = new StorageApi(serverUrl, runId);
28
+ }
29
+ return this.#api;
30
+ }
31
+
32
+ /**
33
+ * Get value by key or compute it if not found.
34
+ */
35
+ // eslint-disable-next-line visual/complexity, max-statements
36
+ async get<K extends StringKeys<S>>(...args: GetArgs<K, S>): Promise<S[K]> {
37
+ const { key, params, fn } = resolveGetArgs(args);
38
+
39
+ if (globalConfig.disabled) {
40
+ debugKey(key, `Global cache disabled. Computing...`);
41
+ return fn();
42
+ }
43
+
44
+ const ttl = globalConfig.ignoreTTL ? undefined : params.ttl;
45
+
46
+ // keep stack expression in this fn to have correct stack offset
47
+ const stack = new Error().stack?.split('\n')[2]?.trim().replace(process.cwd(), '') || '';
48
+ const sig = calcSignature({ fn, ttl, stack });
49
+
50
+ debugKey(key, `Fetching value...`);
51
+ const body = await this.api.get({ key, sig, ttl });
52
+
53
+ if (body.result === 'cache-hit') {
54
+ const { value } = body.valueInfo;
55
+ debugKey(key, `${body.result}: ${previewValue(value)}`);
56
+ return value as S[K];
57
+ }
58
+
59
+ if (body.result === 'error') {
60
+ throw new Error(body.message);
61
+ }
62
+
63
+ if (body.result === 'sig-mismatch') {
64
+ logger.warn(body.message);
65
+ debugKey(key, `${body.result}, computing...`);
66
+ } else {
67
+ debugKey(key, `${body.result} (${body.message}), computing...`);
68
+ }
69
+
70
+ const { value, error } = await this.computeValue(fn);
71
+ debugKey(key, error ? `Error: ${error.message}` : `Computed: ${previewValue(value)}`);
72
+
73
+ if (body.result === 'sig-mismatch') {
74
+ debugKey(key, `Not saving value because of signature mismatch.`);
75
+ if (error) throw error;
76
+ return value;
77
+ }
78
+
79
+ debugKey(key, `Saving value...`);
80
+ const valueInfo = await this.api.set({ key, value, error });
81
+ debugKey(key, `Saved.`);
82
+
83
+ if (error) throw error;
84
+
85
+ // We return 'valueInfo.value' instead of 'value' to have exact the same value
86
+ // as stored (in case of serialization changes).
87
+ return valueInfo.value as S[K];
88
+ }
89
+
90
+ /**
91
+ * Fetch stale value.
92
+ * - for non-persistant keys it would be the current value
93
+ * - for persistent keys it would be the old value if it was changed during this run
94
+ */
95
+ async getStale<K extends StringKeys<S>>(key: K) {
96
+ debugKey(key, `Fetching stale value...`);
97
+ const value = await this.api.getStale({ key });
98
+ debugKey(key, `Fetched: ${previewValue(value)}`);
99
+
100
+ return value as S[K] | undefined;
101
+ }
102
+
103
+ /**
104
+ * Fetch list of stale values by prefix.
105
+ * - for non-persistant keys it would be the current value
106
+ * - for persistent keys it would be the old value if it was changed during this run
107
+ */
108
+ async getStaleList<ValueType>(prefix: string) {
109
+ debugKey(prefix, `Fetching stale list...`);
110
+ const values = await this.api.getStaleList({ prefix });
111
+ debugKey(prefix, `Fetched: ${values.length} value(s)`);
112
+
113
+ return values as ValueType[];
114
+ }
115
+
116
+ async clear() {
117
+ debug('Clearing test-run values...');
118
+ await this.api.clear();
119
+ debug('Cleared.');
120
+ }
121
+
122
+ private async computeValue<ValueType>(fn: () => ValueType) {
123
+ try {
124
+ const value = await fn();
125
+ return { value };
126
+ } catch (e) {
127
+ const error = e instanceof Error ? e : new Error(String(e));
128
+ return { error };
129
+ }
130
+ }
131
+ }
132
+
133
+ function resolveGetArgs<K extends StringKeys<S>, S extends DefaultKeysSchema>(args: GetArgs<K, S>) {
134
+ return args.length === 2
135
+ ? { key: args[0], params: {}, fn: args[1] }
136
+ : { key: args[0], params: { ...args[1] }, fn: args[2] };
137
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Helper types.
3
+ */
4
+ import { TTL } from '../shared/ttl';
5
+
6
+ // Key-value types for strictly typed storage.
7
+ export type DefaultKeysSchema = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
8
+ export type StringKeys<S extends DefaultKeysSchema> = keyof S & string; // strip number | symbol;
9
+
10
+ export type ComputeFn<Res> = () => Res | Promise<Res>;
11
+ export type KeyParams = { ttl?: TTL };
12
+ export type GetArgs<K extends StringKeys<S>, S extends DefaultKeysSchema> =
13
+ | [K, ComputeFn<S[K]>]
14
+ | [K, KeyParams, ComputeFn<S[K]>];
@@ -0,0 +1,8 @@
1
+ export async function buildHttpError(res: Response, prefix: string) {
2
+ const message = (await res.text()) || `${res.status} ${res.statusText}`;
3
+ return new Error(`${prefix} ${message}`);
4
+ }
5
+
6
+ export async function throwIfHttpError(res: Response, message: string) {
7
+ if (!res.ok) throw await buildHttpError(res, message);
8
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Simple http client for json payloads.
3
+ */
4
+
5
+ import { addSearchParams } from './http-query';
6
+
7
+ const headers = { 'Content-Type': 'application/json' };
8
+
9
+ export type QueryParams<T extends Record<string, unknown>> = {
10
+ [K in keyof T]: string;
11
+ };
12
+
13
+ export class HttpJson {
14
+ constructor(private baseUrl: string) {}
15
+
16
+ async get(pathname: string, query?: Record<string, string>) {
17
+ const url = this.buildUrl(pathname, query);
18
+ return fetch(url, { method: 'GET', headers });
19
+ }
20
+
21
+ async post(pathname: string, bodyObj?: Record<string, unknown>) {
22
+ const url = this.buildUrl(pathname);
23
+ const body = bodyObj ? JSON.stringify(bodyObj) : undefined;
24
+ return fetch(url, { method: 'POST', headers, body });
25
+ }
26
+
27
+ buildUrl(pathname: string, query?: Record<string, string>) {
28
+ const url = new URL(pathname, this.baseUrl);
29
+ addSearchParams(url, query);
30
+ return url.toString();
31
+ }
32
+ }
@@ -0,0 +1,24 @@
1
+ import { QueryParams } from './http-json';
2
+
3
+ export function addSearchParams(url: URL, query?: Record<string, string>) {
4
+ const preparedQuery = prepareQueryParams(query || {});
5
+ Object.entries(preparedQuery).forEach(([key, value]) => {
6
+ url.searchParams.append(key, String(value));
7
+ });
8
+ }
9
+
10
+ /**
11
+ * Converts record of parameters to query string format.
12
+ * - undefined values are ignored
13
+ * - values are converted to strings
14
+ */
15
+ export function prepareQueryParams<T extends Record<string, unknown>>(params?: T): QueryParams<T> {
16
+ const query: QueryParams<T> = {} as QueryParams<T>;
17
+ Object.entries(params || {}).forEach(([key, value]) => {
18
+ if (value !== undefined) {
19
+ query[key as keyof T] = String(value);
20
+ }
21
+ });
22
+
23
+ return query;
24
+ }
@@ -0,0 +1,10 @@
1
+ export function previewValue(value: unknown) {
2
+ try {
3
+ if (value === undefined) return '<undefined>';
4
+ const strValue = JSON.stringify(value);
5
+ return strValue.length > 50 ? `${strValue.slice(0, 50)}...` : strValue;
6
+ } catch {
7
+ // Fallback for non-serializable values
8
+ return `<non-serializable>`;
9
+ }
10
+ }
package/src/config.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Global config is shared across host and all workers via env variables.
3
+ * Assumption here is that all runners start workers with the same env.
4
+ */
5
+ import { randomUUID } from 'node:crypto';
6
+ import { removeUndefined } from './shared/utils/object';
7
+
8
+ export type GlobalCacheConfig = {
9
+ /* Forces all values to be non-persistent, usefull for CI */
10
+ ignoreTTL?: boolean;
11
+ /* Where to store files, ignored if serverUrl is set */
12
+ basePath?: string;
13
+ /* Disables global storage, all values will be computed each time */
14
+ disabled?: boolean;
15
+ /* External server url */
16
+ serverUrl?: string;
17
+ };
18
+
19
+ type GlobalConfigInternal = GlobalCacheConfig & {
20
+ serverUrl: string;
21
+ runId: string;
22
+ };
23
+
24
+ export class GlobalConfig {
25
+ private config: GlobalConfigInternal = {
26
+ serverUrl: '', // will be set later, when server starts.
27
+ runId: '', // will be set later
28
+ };
29
+
30
+ constructor() {
31
+ Object.assign(this.config, getConfigFromEnv());
32
+ if (!this.runId) this.newTestRun();
33
+ }
34
+
35
+ update(config: Partial<GlobalConfigInternal>) {
36
+ Object.assign(this.config, removeUndefined(config));
37
+ saveConfigToEnv(this.config);
38
+ }
39
+
40
+ /**
41
+ * Generate new runId or re-use from env variable.
42
+ * There can be multiple test runs within single server session.
43
+ */
44
+ newTestRun() {
45
+ this.update({ runId: process.env.GLOBAL_CACHE_RUN_ID || randomUUID() });
46
+ }
47
+
48
+ get serverUrl() {
49
+ return this.config.serverUrl;
50
+ }
51
+
52
+ get ignoreTTL() {
53
+ return Boolean(this.config.ignoreTTL);
54
+ }
55
+
56
+ get basePath() {
57
+ return this.config.basePath;
58
+ }
59
+
60
+ get disabled() {
61
+ return Boolean(this.config.disabled);
62
+ }
63
+
64
+ get runId() {
65
+ return this.config.runId;
66
+ }
67
+ }
68
+
69
+ function getConfigFromEnv() {
70
+ const configFromEnv = process.env.GLOBAL_STORAGE_CONFIG;
71
+ return configFromEnv ? (JSON.parse(configFromEnv) as GlobalCacheConfig) : undefined;
72
+ }
73
+
74
+ function saveConfigToEnv(config: GlobalCacheConfig) {
75
+ process.env.GLOBAL_STORAGE_CONFIG = JSON.stringify(config);
76
+ }
77
+
78
+ /* Export singleton instance */
79
+ export const globalConfig = new GlobalConfig();
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { GlobalCacheClient, DefaultKeysSchema } from './client/index.js';
2
+ export { globalConfig, GlobalCacheConfig } from './config.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Global storage server configuration.
3
+ */
4
+ import { Express } from 'express';
5
+ import { removeUndefined } from '../shared/utils/object';
6
+
7
+ export type GlobalCacheServerConfig = {
8
+ port?: number;
9
+ basePath?: string;
10
+ multiInstance?: boolean;
11
+ };
12
+
13
+ const defaults: Pick<Required<GlobalCacheServerConfig>, 'basePath'> = {
14
+ basePath: '.global-cache',
15
+ };
16
+
17
+ export type GlobalCacheServerConfigResolved = ReturnType<typeof buildResolvedConfig>;
18
+
19
+ export function setConfig(app: Express, providedConfig: GlobalCacheServerConfig) {
20
+ app.locals.config = buildResolvedConfig(providedConfig);
21
+ }
22
+
23
+ export function getConfig(app: Express) {
24
+ return app.locals.config as GlobalCacheServerConfigResolved;
25
+ }
26
+
27
+ function buildResolvedConfig(providedConfig: GlobalCacheServerConfig) {
28
+ return Object.assign({}, defaults, removeUndefined(providedConfig));
29
+ }
@@ -0,0 +1,7 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+
3
+ // eslint-disable-next-line max-params
4
+ export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
5
+ const message = (err as Error)?.message || String(err);
6
+ res.status(500).send(message);
7
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Handles "get" request for single-instance storage.
3
+ */
4
+ import { checkSignature, SignatureMismatch } from '../shared/sig';
5
+ import { isExpired } from '../shared/ttl';
6
+ import { TestRunValueInfo } from '../shared/value-info';
7
+ import { IPersistentStorage, ITestRunStorage } from './storage/types';
8
+
9
+ type GetterCacheMiss = { result: 'cache-miss'; message: string };
10
+ type GetterCacheHit = { result: 'cache-hit'; valueInfo: TestRunValueInfo };
11
+ type GetterError = { result: 'error'; message: string };
12
+ type GetterSingatureMismatch = { result: 'sig-mismatch'; message: string };
13
+
14
+ export type GetterResult = GetterCacheMiss | GetterCacheHit | GetterError | GetterSingatureMismatch;
15
+
16
+ export class Getter {
17
+ constructor(
18
+ private testRunStorage: ITestRunStorage,
19
+ private persistentStorage: IPersistentStorage,
20
+ ) {}
21
+
22
+ // eslint-disable-next-line visual/complexity, max-statements
23
+ async get(key: string, sig: string, ttl?: number): Promise<GetterResult> {
24
+ let valueInfo = await this.testRunStorage.load(key);
25
+
26
+ // check signature (for already accessed value)
27
+ if (valueInfo) {
28
+ const signatureMismatch = checkSignature(key, valueInfo.sig, sig);
29
+ if (signatureMismatch) {
30
+ return this.handleSignatureMismatch(key, signatureMismatch);
31
+ }
32
+ }
33
+
34
+ // wait if computing
35
+ if (valueInfo?.state === 'computing') {
36
+ valueInfo = await this.testRunStorage.wait(key);
37
+ return valueInfo.state === 'computed'
38
+ ? this.handleComputed(valueInfo)
39
+ : this.handleError(valueInfo);
40
+ }
41
+
42
+ // load from persistent storage
43
+ if (!valueInfo && ttl) {
44
+ valueInfo = await this.persistentStorage.load(key);
45
+ if (valueInfo) {
46
+ const signatureMismatch = checkSignature(key, valueInfo.sig, sig);
47
+ // eslint-disable-next-line max-depth
48
+ if (signatureMismatch) {
49
+ return this.handleSignatureChanged(valueInfo, sig, signatureMismatch);
50
+ }
51
+ }
52
+ }
53
+
54
+ // check expired
55
+ if (valueInfo?.state === 'computed' && valueInfo.persistent && ttl) {
56
+ if (isExpired(valueInfo.computedAt, ttl)) {
57
+ return this.handleExpired(valueInfo, ttl);
58
+ }
59
+ }
60
+
61
+ // normal cache hit
62
+ if (valueInfo?.state === 'computed') {
63
+ return this.handleComputed(valueInfo);
64
+ }
65
+
66
+ // no value
67
+ if (!valueInfo) {
68
+ return this.handleNoValue(key, sig, ttl);
69
+ }
70
+
71
+ return this.handleDefault(valueInfo);
72
+ }
73
+
74
+ private handleSignatureMismatch(
75
+ key: string,
76
+ signatureMismatch: SignatureMismatch,
77
+ ): GetterSingatureMismatch {
78
+ return { result: 'sig-mismatch', message: buildSignatureError(key, signatureMismatch) };
79
+ }
80
+
81
+ private handleComputed(valueInfo: TestRunValueInfo): GetterCacheHit {
82
+ return { result: 'cache-hit', valueInfo };
83
+ }
84
+
85
+ private handleError(valueInfo: TestRunValueInfo): GetterError {
86
+ return { result: 'error', message: valueInfo.errorMessage || 'Unknown error' };
87
+ }
88
+
89
+ private async handleSignatureChanged(
90
+ loadedValueInfo: TestRunValueInfo,
91
+ newSig: string,
92
+ { field }: SignatureMismatch,
93
+ ): Promise<GetterCacheMiss> {
94
+ await this.updateValueInfo(loadedValueInfo, {
95
+ state: 'computing',
96
+ sig: newSig,
97
+ prevValue: loadedValueInfo.value,
98
+ value: undefined,
99
+ computedAt: undefined,
100
+ });
101
+ return { result: 'cache-miss', message: `signature changed: ${field}` };
102
+ }
103
+
104
+ private async handleExpired(valueInfo: TestRunValueInfo, ttl: number): Promise<GetterCacheMiss> {
105
+ await this.updateValueInfo(valueInfo, {
106
+ state: 'computing',
107
+ prevValue: valueInfo.value,
108
+ value: undefined,
109
+ computedAt: undefined,
110
+ });
111
+ return { result: 'cache-miss', message: `expired: ${ttl}` };
112
+ }
113
+
114
+ private async handleNoValue(key: string, sig: string, ttl?: number): Promise<GetterCacheMiss> {
115
+ await this.updateValueInfo({ key, state: 'computing', sig, persistent: Boolean(ttl) });
116
+ return { result: 'cache-miss', message: 'no cached value' };
117
+ }
118
+
119
+ private async handleDefault(valueInfo: TestRunValueInfo): Promise<GetterCacheMiss> {
120
+ const message = `old state: ${valueInfo.state}`;
121
+ await this.updateValueInfo(valueInfo, { state: 'computing' });
122
+ return { result: 'cache-miss', message };
123
+ }
124
+
125
+ private async updateValueInfo(valueInfo: TestRunValueInfo, props?: Partial<TestRunValueInfo>) {
126
+ Object.assign(valueInfo, props);
127
+ await this.testRunStorage.save(valueInfo);
128
+ }
129
+ }
130
+
131
+ function buildSignatureError(key: string, { field, value1, value2 }: SignatureMismatch) {
132
+ return [
133
+ `Signature mismatch (${field}). `,
134
+ `Please ensure you don't call globalCache.get("${key}") from multiple places.\n`,
135
+ `1-st call ${field}: ${value1}\n`,
136
+ `2-nd call ${field}: ${value2}`,
137
+ ].join('');
138
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Storage Server.
3
+ * An HTTP server that provides a simple key-value globalCache.
4
+ * Can be launched locally or on a dedicated environment (standalone).
5
+ *
6
+ * This file is exported separately from the main index file, to not be loaded in workers.
7
+ */
8
+ import http from 'node:http';
9
+ import { AddressInfo } from 'net';
10
+ import express from 'express';
11
+ import { debug } from '../shared/debug';
12
+ import { router as routeGet } from './routes/get';
13
+ import { router as routeSet } from './routes/set';
14
+ import { router as routeGetStale } from './routes/get-stale';
15
+ import { router as routeGetStaleList } from './routes/get-stale-list';
16
+ import { router as routeClearSession } from './routes/clear';
17
+ import { errorHandler } from './error';
18
+ import { getConfig, GlobalCacheServerConfig, setConfig } from './config';
19
+ import { getStorage } from './storage';
20
+ import { startExpressServer } from './utils/express';
21
+
22
+ export class GlobalCacheServer {
23
+ private app = express();
24
+ private server: http.Server | null = null;
25
+
26
+ constructor() {
27
+ this.app.use(express.json());
28
+ this.app.use('/', routeGet);
29
+ this.app.use('/', routeSet);
30
+ this.app.use('/', routeGetStale);
31
+ this.app.use('/', routeGetStaleList);
32
+ this.app.use('/', routeClearSession);
33
+ // todo:
34
+ // this.app.get('/', (req, res) => {
35
+ // res.send('Global Storage Server is running.');
36
+ // });
37
+ // Must be after all other middleware and routes
38
+ this.app.use(errorHandler);
39
+ }
40
+
41
+ get port() {
42
+ const { port = 0 } = this.server ? (this.server.address() as AddressInfo) : {};
43
+ return port;
44
+ }
45
+
46
+ get localUrl() {
47
+ return `http://localhost:${this.port}`;
48
+ }
49
+
50
+ get isRunning() {
51
+ return Boolean(this.server?.listening);
52
+ }
53
+
54
+ async start(config: GlobalCacheServerConfig) {
55
+ // todo: if server is already running?
56
+ debug('Starting server...');
57
+ setConfig(this.app, config);
58
+ this.server = await startExpressServer(this.app, config.port);
59
+ debug(`Server started on port: ${this.port}`);
60
+ }
61
+
62
+ async stop() {
63
+ if (!this.isRunning) return;
64
+ debug('Stopping server...');
65
+ await new Promise<void>((resolve, reject) => {
66
+ this.server?.close((err) => (err ? reject(err) : resolve()));
67
+ });
68
+ this.server = null;
69
+ debug('Server stopped.');
70
+ }
71
+
72
+ async clearTestRun(runId: string) {
73
+ if (!this.isRunning) return;
74
+ const config = getConfig(this.app);
75
+ const { testRunStorage } = getStorage(config, runId);
76
+ await testRunStorage.clear();
77
+ }
78
+ }
79
+
80
+ /* Export a default instance - convenient for usage */
81
+ export const globalCacheServer = new GlobalCacheServer();
@@ -0,0 +1,15 @@
1
+ import { Express, Router } from 'express';
2
+ import { getConfig } from '../config';
3
+ import { getStorage } from '../storage';
4
+
5
+ export const router = Router();
6
+
7
+ router.post('/run/:runId/clear', async (req, res) => {
8
+ const { runId } = req.params;
9
+ const config = getConfig(req.app as Express);
10
+
11
+ const { testRunStorage } = getStorage(config, runId);
12
+ await testRunStorage.clear();
13
+
14
+ res.end();
15
+ });
@@ -0,0 +1,27 @@
1
+ import { Express, Router } from 'express';
2
+ import { getConfig } from '../config';
3
+ import { getStorage } from '../storage';
4
+ import { TestRunValueInfo } from '../../shared/value-info';
5
+
6
+ export const router = Router();
7
+
8
+ export type GetStaleListParams = {
9
+ prefix: string;
10
+ };
11
+
12
+ export type SetStaleListResponse = TestRunValueInfo[];
13
+
14
+ /**
15
+ * Returns a list of stale values by prefix.
16
+ * For stale we return only values used in this test-run.
17
+ */
18
+ router.get('/run/:runId/get-stale-list', async (req, res) => {
19
+ const { runId } = req.params;
20
+ const { prefix } = req.query as GetStaleListParams;
21
+ const config = getConfig(req.app as Express);
22
+
23
+ const { testRunStorage } = getStorage(config, runId);
24
+ const valueInfoList = await testRunStorage.loadByPrefix(prefix);
25
+
26
+ res.json(valueInfoList);
27
+ });
@@ -0,0 +1,27 @@
1
+ import { Express, Router } from 'express';
2
+ import { getConfig } from '../config';
3
+ import { getStorage } from '../storage';
4
+ import { TestRunValueInfo } from '../../shared/value-info';
5
+
6
+ export const router = Router();
7
+
8
+ export type GetStaleParams = {
9
+ key: string;
10
+ };
11
+
12
+ export type SetStaleResponse = TestRunValueInfo | null;
13
+
14
+ /**
15
+ * Returns a stale value.
16
+ * For stale we return only values used in this test-run.
17
+ */
18
+ router.get('/run/:runId/get-stale', async (req, res) => {
19
+ const { runId } = req.params;
20
+ const { key } = req.query as GetStaleParams;
21
+ const config = getConfig(req.app as Express);
22
+
23
+ const { testRunStorage } = getStorage(config, runId);
24
+ const valueInfo = await testRunStorage.load(key);
25
+
26
+ res.json(valueInfo ?? null);
27
+ });
@@ -0,0 +1,31 @@
1
+ import { Express, Router } from 'express';
2
+ import { parseTTL } from '../../shared/ttl';
3
+ import { getConfig } from '../config';
4
+ import { Getter, GetterResult } from '../getter';
5
+ import { getStorage } from '../storage';
6
+
7
+ export const router = Router();
8
+
9
+ export type GetValueParams = {
10
+ key: string;
11
+ sig: string;
12
+ ttl?: string;
13
+ };
14
+
15
+ export type GetValueResponse = GetterResult;
16
+
17
+ /**
18
+ * Route for geting a value.
19
+ */
20
+ router.get('/run/:runId/get', async (req, res) => {
21
+ const { runId } = req.params;
22
+ const { key, sig, ttl: ttlParam } = req.query as GetValueParams;
23
+ const config = getConfig(req.app as Express);
24
+ const ttl = parseTTL(ttlParam);
25
+
26
+ const { testRunStorage, persistentStorage } = getStorage(config, runId);
27
+ const getter = new Getter(testRunStorage, persistentStorage);
28
+ const data = await getter.get(key, sig, ttl);
29
+
30
+ res.json(data);
31
+ });