feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "metro-cache",
"version": "0.80.12",
"description": "🚇 Cache layers for Metro.",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "git@github.com:facebook/metro.git"
},
"scripts": {
"prepare-release": "test -d build && rm -rf src.real && mv src src.real && mv build src",
"cleanup-release": "test ! -e build && mv src build && mv src.real src"
},
"dependencies": {
"exponential-backoff": "^3.1.1",
"flow-enums-runtime": "^0.0.6",
"metro-core": "0.80.12"
},
"devDependencies": {
"metro-memory-fs": "0.80.12"
},
"license": "MIT",
"engines": {
"node": ">=18"
}
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
import {CacheStore} from './types';
/**
* Main cache class. Receives an array of cache instances, and sequentially
* traverses them to return a previously stored value. It also ensures setting
* the value in all instances.
*
* All get/set operations are logged via Metro's logger.
*/
export default class Cache<T> {
constructor(stores: ReadonlyArray<CacheStore<T>>);
get(key: Buffer): Promise<T | null>;
set(key: Buffer, value: T): Promise<void>;
get isDisabled(): boolean;
}

View File

@@ -0,0 +1,98 @@
"use strict";
const { Logger } = require("metro-core");
class Cache {
constructor(stores) {
this._hits = new WeakMap();
this._stores = stores;
}
async get(key) {
const stores = this._stores;
const length = stores.length;
for (let i = 0; i < length; i++) {
const store = stores[i];
const storeName = store.name ?? store.constructor.name;
const name = storeName + "::" + key.toString("hex");
let value = null;
const logStart = Logger.log(
Logger.createActionStartEntry({
action_name: "Cache get",
log_entry_label: name,
})
);
try {
const valueOrPromise = store.get(key);
if (valueOrPromise && typeof valueOrPromise.then === "function") {
value = await valueOrPromise;
} else {
value = valueOrPromise;
}
} finally {
const hitOrMiss = value != null ? "hit" : "miss";
Logger.log({
...Logger.createActionEndEntry(logStart),
action_result: hitOrMiss,
});
Logger.log(
Logger.createEntry({
action_name: "Cache " + hitOrMiss,
log_entry_label: name,
})
);
if (value != null) {
this._hits.set(key, store);
return value;
}
}
}
return null;
}
async set(key, value) {
const stores = this._stores;
const stop = this._hits.get(key);
const length = stores.length;
const promises = [];
const writeErrors = [];
const storesWithErrors = new Set();
for (let i = 0; i < length && stores[i] !== stop; i++) {
const store = stores[i];
const storeName = store.name ?? store.constructor.name;
const name = storeName + "::" + key.toString("hex");
const logStart = Logger.log(
Logger.createActionStartEntry({
action_name: "Cache set",
log_entry_label: name,
})
);
promises.push(
(async () => {
try {
await stores[i].set(key, value);
Logger.log(Logger.createActionEndEntry(logStart));
} catch (e) {
Logger.log(Logger.createActionEndEntry(logStart, e));
storesWithErrors.add(storeName);
writeErrors.push(
new Error(`Cache write failed for ${name}`, {
cause: e,
})
);
}
})()
);
}
await Promise.allSettled(promises);
if (writeErrors.length > 0) {
throw new AggregateError(
writeErrors,
`Cache write failed for store(s): ${Array.from(storesWithErrors).join(
", "
)}`
);
}
}
get isDisabled() {
return this._stores.length === 0;
}
}
module.exports = Cache;

View File

@@ -0,0 +1,142 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
* @oncall react_native
*/
'use strict';
import type {CacheStore} from 'metro-cache';
const {Logger} = require('metro-core');
/**
* Main cache class. Receives an array of cache instances, and sequentially
* traverses them to return a previously stored value. It also ensures setting
* the value in all instances.
*
* All get/set operations are logged via Metro's logger.
*/
class Cache<T> {
_stores: $ReadOnlyArray<CacheStore<T>>;
_hits: WeakMap<Buffer, CacheStore<T>>;
constructor(stores: $ReadOnlyArray<CacheStore<T>>) {
this._hits = new WeakMap();
this._stores = stores;
}
async get(key: Buffer): Promise<?T> {
const stores = this._stores;
const length = stores.length;
for (let i = 0; i < length; i++) {
const store = stores[i];
const storeName = store.name ?? store.constructor.name;
const name = storeName + '::' + key.toString('hex');
let value = null;
const logStart = Logger.log(
Logger.createActionStartEntry({
action_name: 'Cache get',
log_entry_label: name,
}),
);
try {
const valueOrPromise = store.get(key);
// $FlowFixMe[method-unbinding] added when improving typing for this parameters
if (valueOrPromise && typeof valueOrPromise.then === 'function') {
value = await valueOrPromise;
} else {
value = valueOrPromise;
}
} finally {
const hitOrMiss = value != null ? 'hit' : 'miss';
Logger.log({
...Logger.createActionEndEntry(logStart),
action_result: hitOrMiss,
});
// Deprecated - will be removed () - use 'Cache get' and action_result
// (above) instead.
// TODO: T196506422
Logger.log(
Logger.createEntry({
action_name: 'Cache ' + hitOrMiss,
log_entry_label: name,
}),
);
if (value != null) {
this._hits.set(key, store);
return value;
}
}
}
return null;
}
async set(key: Buffer, value: T): Promise<void> {
const stores = this._stores;
const stop = this._hits.get(key);
const length = stores.length;
const promises = [];
const writeErrors = [];
const storesWithErrors = new Set<string>();
for (let i = 0; i < length && stores[i] !== stop; i++) {
const store = stores[i];
const storeName = store.name ?? store.constructor.name;
const name = storeName + '::' + key.toString('hex');
const logStart = Logger.log(
Logger.createActionStartEntry({
action_name: 'Cache set',
log_entry_label: name,
}),
);
promises.push(
(async () => {
try {
await stores[i].set(key, value);
Logger.log(Logger.createActionEndEntry(logStart));
} catch (e) {
Logger.log(Logger.createActionEndEntry(logStart, e));
storesWithErrors.add(storeName);
writeErrors.push(
new Error(`Cache write failed for ${name}`, {cause: e}),
);
}
})(),
);
}
await Promise.allSettled(promises);
if (writeErrors.length > 0) {
throw new AggregateError(
writeErrors,
`Cache write failed for store(s): ${Array.from(storesWithErrors).join(', ')}`,
);
}
}
// Returns true if the current configuration disables the cache, such that
// writing to the cache is a no-op and reading from the cache will always
// return null.
get isDisabled(): boolean {
return this._stores.length === 0;
}
}
module.exports = Cache;

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
// <reference types="node" />
import Cache from './Cache';
import stableHash from './stableHash';
import AutoCleanFileStore from './stores/AutoCleanFileStore';
import FileStore from './stores/FileStore';
import HttpGetStore from './stores/HttpGetStore';
import HttpStore from './stores/HttpStore';
export type {Options as FileOptions} from './stores/FileStore';
export type {Options as HttpOptions} from './stores/HttpStore';
export type {CacheStore} from './types';
export interface MetroCache {
AutoCleanFileStore: typeof AutoCleanFileStore;
Cache: typeof Cache;
FileStore: typeof FileStore;
HttpGetStore: typeof HttpGetStore;
HttpStore: typeof HttpStore;
stableHash: typeof stableHash;
}
export {
AutoCleanFileStore,
Cache,
FileStore,
HttpGetStore,
HttpStore,
stableHash,
};

View File

@@ -0,0 +1,14 @@
"use strict";
const Cache = require("./Cache");
const stableHash = require("./stableHash");
const AutoCleanFileStore = require("./stores/AutoCleanFileStore");
const FileStore = require("./stores/FileStore");
const HttpGetStore = require("./stores/HttpGetStore");
const HttpStore = require("./stores/HttpStore");
module.exports.AutoCleanFileStore = AutoCleanFileStore;
module.exports.Cache = Cache;
module.exports.FileStore = FileStore;
module.exports.HttpGetStore = HttpGetStore;
module.exports.HttpStore = HttpStore;
module.exports.stableHash = stableHash;

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/
'use strict';
const Cache = require('./Cache');
const stableHash = require('./stableHash');
const AutoCleanFileStore = require('./stores/AutoCleanFileStore');
const FileStore = require('./stores/FileStore');
const HttpGetStore = require('./stores/HttpGetStore');
const HttpStore = require('./stores/HttpStore');
export type {Options as FileOptions} from './stores/FileStore';
export type {Options as HttpOptions} from './stores/HttpStore';
export type {CacheStore} from './types.flow';
module.exports.AutoCleanFileStore = AutoCleanFileStore;
module.exports.Cache = Cache;
module.exports.FileStore = FileStore;
module.exports.HttpGetStore = HttpGetStore;
module.exports.HttpStore = HttpStore;
module.exports.stableHash = stableHash;

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
export default function stableHash(value: unknown): Buffer;

View File

@@ -0,0 +1,11 @@
"use strict";
const crypto = require("crypto");
const canonicalize = require("metro-core/src/canonicalize");
function stableHash(value) {
return crypto
.createHash("md5")
.update(JSON.stringify(value, canonicalize))
.digest("buffer");
}
module.exports = stableHash;

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/
'use strict';
const crypto = require('crypto');
const canonicalize = require('metro-core/src/canonicalize');
function stableHash(value: mixed): Buffer {
return (
crypto
.createHash('md5')
/* $FlowFixMe(>=0.95.0 site=react_native_fb) This comment suppresses an
* error found when Flow v0.95 was deployed. To see the error, delete this
* comment and run Flow. */
.update(JSON.stringify(value, canonicalize))
.digest('buffer')
);
}
module.exports = stableHash;

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
import type FileStore from './FileStore';
export default class AutoCleanFileStore<T> extends FileStore<T> {}

View File

@@ -0,0 +1,64 @@
"use strict";
const FileStore = require("./FileStore");
const fs = require("fs");
const path = require("path");
const walkSync = function (dir, filelist) {
const files = fs.readdirSync(dir);
filelist = filelist || [];
files.forEach(function (file) {
const fullPath = path.join(dir, file);
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
filelist = walkSync(fullPath + path.sep, filelist);
} else {
filelist.push({
path: fullPath,
stats,
});
}
});
return filelist;
};
function get(property, defaultValue) {
if (property == null) {
return defaultValue;
}
return property;
}
class AutoCleanFileStore extends FileStore {
constructor(opts) {
super({
root: opts.root,
});
this._intervalMs = get(opts.intervalMs, 10 * 60 * 1000);
this._cleanupThresholdMs = get(
opts.cleanupThresholdMs,
3 * 24 * 60 * 60 * 1000
);
this._scheduleCleanup();
}
_scheduleCleanup() {
setTimeout(this._doCleanup.bind(this), this._intervalMs);
}
_doCleanup() {
const files = walkSync(this._root, []);
let warned = false;
files.forEach((file) => {
if (file.stats.mtimeMs < Date.now() - this._cleanupThresholdMs) {
try {
fs.unlinkSync(file.path);
} catch (e) {
if (!warned) {
console.warn(
"Problem cleaning up cache for " + file.path + ": " + e.message
);
warned = true;
}
}
}
});
this._scheduleCleanup();
}
}
module.exports = AutoCleanFileStore;

View File

@@ -0,0 +1,107 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
'use strict';
import type {Options} from './FileStore';
const FileStore = require('./FileStore');
const fs = require('fs');
const path = require('path');
type CleanOptions = {
...Options,
intervalMs?: number,
cleanupThresholdMs?: number,
...
};
type FileList = {
path: string,
stats: fs.Stats,
...
};
// List all files in a directory in Node.js recursively in a synchronous fashion
const walkSync = function (
dir: string,
filelist: Array<FileList>,
): Array<FileList> {
const files = fs.readdirSync(dir);
filelist = filelist || [];
files.forEach(function (file) {
const fullPath = path.join(dir, file);
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
filelist = walkSync(fullPath + path.sep, filelist);
} else {
filelist.push({path: fullPath, stats});
}
});
return filelist;
};
function get<T>(property: ?T, defaultValue: T): T {
if (property == null) {
return defaultValue;
}
return property;
}
/**
* A FileStore that cleans itself up in a given interval
*/
class AutoCleanFileStore<T> extends FileStore<T> {
_intervalMs: number;
_cleanupThresholdMs: number;
_root: string;
constructor(opts: CleanOptions) {
super({root: opts.root});
this._intervalMs = get(opts.intervalMs, 10 * 60 * 1000); // 10 minutes
this._cleanupThresholdMs = get(
opts.cleanupThresholdMs,
3 * 24 * 60 * 60 * 1000, // 3 days
);
this._scheduleCleanup();
}
_scheduleCleanup() {
// $FlowFixMe[method-unbinding] added when improving typing for this parameters
setTimeout(this._doCleanup.bind(this), this._intervalMs);
}
_doCleanup() {
const files = walkSync(this._root, []);
let warned = false;
files.forEach(file => {
if (file.stats.mtimeMs < Date.now() - this._cleanupThresholdMs) {
try {
fs.unlinkSync(file.path);
} catch (e) {
if (!warned) {
console.warn(
'Problem cleaning up cache for ' + file.path + ': ' + e.message,
);
warned = true;
}
}
}
});
this._scheduleCleanup();
}
}
module.exports = AutoCleanFileStore;

View File

@@ -0,0 +1,20 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
export interface Options {
root: string;
}
export default class FileStore<T> {
constructor(options: Options);
get(key: Buffer): Promise<T | null>;
set(key: Buffer, value: T): Promise<void>;
clear(): void;
}

View File

@@ -0,0 +1,77 @@
"use strict";
const fs = require("fs");
const path = require("path");
const NULL_BYTE = 0x00;
const NULL_BYTE_BUFFER = Buffer.from([NULL_BYTE]);
class FileStore {
constructor(options) {
this._root = options.root;
this._createDirs();
}
async get(key) {
try {
const data = await fs.promises.readFile(this._getFilePath(key));
if (data[0] === NULL_BYTE) {
return data.slice(1);
}
return JSON.parse(data.toString("utf8"));
} catch (err) {
if (err.code === "ENOENT" || err instanceof SyntaxError) {
return null;
}
throw err;
}
}
async set(key, value) {
const filePath = this._getFilePath(key);
try {
await this._set(filePath, value);
} catch (err) {
if (err.code === "ENOENT") {
fs.mkdirSync(path.dirname(filePath), {
recursive: true,
});
await this._set(filePath, value);
} else {
throw err;
}
}
}
async _set(filePath, value) {
let content;
if (value instanceof Buffer) {
content = Buffer.concat([NULL_BYTE_BUFFER, value]);
} else {
content = JSON.stringify(value) ?? JSON.stringify(null);
}
await fs.promises.writeFile(filePath, content);
}
clear() {
this._removeDirs();
this._createDirs();
}
_getFilePath(key) {
return path.join(
this._root,
key.slice(0, 1).toString("hex"),
key.slice(1).toString("hex")
);
}
_createDirs() {
for (let i = 0; i < 256; i++) {
fs.mkdirSync(path.join(this._root, ("0" + i.toString(16)).slice(-2)), {
recursive: true,
});
}
}
_removeDirs() {
for (let i = 0; i < 256; i++) {
fs.rmSync(path.join(this._root, ("0" + i.toString(16)).slice(-2)), {
force: true,
recursive: true,
});
}
}
}
module.exports = FileStore;

View File

@@ -0,0 +1,104 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
'use strict';
const fs = require('fs');
const path = require('path');
const NULL_BYTE = 0x00;
const NULL_BYTE_BUFFER = Buffer.from([NULL_BYTE]);
export type Options = {
root: string,
};
class FileStore<T> {
_root: string;
constructor(options: Options) {
this._root = options.root;
this._createDirs();
}
async get(key: Buffer): Promise<?T> {
try {
const data = await fs.promises.readFile(this._getFilePath(key));
if (data[0] === NULL_BYTE) {
return (data.slice(1): any);
}
return JSON.parse(data.toString('utf8'));
} catch (err) {
if (err.code === 'ENOENT' || err instanceof SyntaxError) {
return null;
}
throw err;
}
}
async set(key: Buffer, value: T): Promise<void> {
const filePath = this._getFilePath(key);
try {
await this._set(filePath, value);
} catch (err) {
if (err.code === 'ENOENT') {
fs.mkdirSync(path.dirname(filePath), {recursive: true});
await this._set(filePath, value);
} else {
throw err;
}
}
}
async _set(filePath: string, value: T): Promise<void> {
let content;
if (value instanceof Buffer) {
content = Buffer.concat([NULL_BYTE_BUFFER, value]);
} else {
content = JSON.stringify(value) ?? JSON.stringify(null);
}
await fs.promises.writeFile(filePath, content);
}
clear() {
this._removeDirs();
this._createDirs();
}
_getFilePath(key: Buffer): string {
return path.join(
this._root,
key.slice(0, 1).toString('hex'),
key.slice(1).toString('hex'),
);
}
_createDirs() {
for (let i = 0; i < 256; i++) {
fs.mkdirSync(path.join(this._root, ('0' + i.toString(16)).slice(-2)), {
recursive: true,
});
}
}
_removeDirs() {
for (let i = 0; i < 256; i++) {
fs.rmSync(path.join(this._root, ('0' + i.toString(16)).slice(-2)), {
force: true,
recursive: true,
});
}
}
}
module.exports = FileStore;

View File

@@ -0,0 +1,9 @@
"use strict";
class HttpError extends Error {
constructor(message, code) {
super(message);
this.code = code;
}
}
module.exports = HttpError;

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow strict
*/
'use strict';
class HttpError extends Error {
code: number;
constructor(message: string, code: number) {
super(message);
this.code = code;
}
}
module.exports = HttpError;

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
import type {Options} from './HttpStore';
export default class HttpGetStore<T> {
constructor(options: Options);
get(key: Buffer): Promise<T | null>;
set(key: Buffer, value: T): Promise<void>;
clear(): void;
}

View File

@@ -0,0 +1,45 @@
"use strict";
const HttpStore = require("./HttpStore");
const { Logger } = require("metro-core");
class HttpGetStore extends HttpStore {
constructor(options) {
super(options);
this._warned = false;
}
async get(key) {
try {
return await super.get(key);
} catch (err) {
if (
!(err instanceof HttpStore.HttpError) &&
!(err instanceof HttpStore.NetworkError)
) {
throw err;
}
this._warn(err);
return null;
}
}
set() {
return Promise.resolve(undefined);
}
_warn(err) {
if (!this._warned) {
process.emitWarning(
[
"Could not connect to the HTTP cache.",
"Original error: " + err.message,
].join(" ")
);
Logger.log(
Logger.createEntry({
action_name: "HttpGetStore:Warning",
log_entry_label: `${err.message} (${err.code})`,
})
);
this._warned = true;
}
}
}
module.exports = HttpGetStore;

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow strict-local
*/
'use strict';
import type HttpError from './HttpError';
import type NetworkError from './NetworkError';
import type {HttpOptions} from 'metro-cache';
const HttpStore = require('./HttpStore');
const {Logger} = require('metro-core');
class HttpGetStore<T> extends HttpStore<T> {
_warned: boolean;
constructor(options: HttpOptions) {
super(options);
this._warned = false;
}
async get(key: Buffer): Promise<?T> {
try {
return await super.get(key);
} catch (err) {
if (
!(err instanceof HttpStore.HttpError) &&
!(err instanceof HttpStore.NetworkError)
) {
throw err;
}
this._warn(err);
return null;
}
}
set(): Promise<void> {
return Promise.resolve(undefined);
}
_warn(err: HttpError | NetworkError) {
if (!this._warned) {
process.emitWarning(
[
'Could not connect to the HTTP cache.',
'Original error: ' + err.message,
].join(' '),
);
Logger.log(
Logger.createEntry({
action_name: 'HttpGetStore:Warning',
log_entry_label: `${err.message} (${err.code})`,
}),
);
this._warned = true;
}
}
}
module.exports = HttpGetStore;

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
export interface Options {
endpoint: string;
family?: 4 | 6;
timeout?: number;
key?: string | ReadonlyArray<string> | Buffer | ReadonlyArray<Buffer>;
cert?: string | ReadonlyArray<string> | Buffer | ReadonlyArray<Buffer>;
ca?: string | ReadonlyArray<string> | Buffer | ReadonlyArray<Buffer>;
}
export default class HttpStore<T> {
constructor(options: Options);
get(key: Buffer): Promise<T | null>;
set(key: Buffer, value: T): Promise<void>;
clear(): void;
}

View File

@@ -0,0 +1,282 @@
"use strict";
const HttpError = require("./HttpError");
const NetworkError = require("./NetworkError");
const { backOff } = require("exponential-backoff");
const http = require("http");
const https = require("https");
const url = require("url");
const zlib = require("zlib");
const ZLIB_OPTIONS = {
level: 9,
};
const NULL_BYTE = 0x00;
const NULL_BYTE_BUFFER = Buffer.from([NULL_BYTE]);
class HttpStore {
static HttpError = HttpError;
static NetworkError = NetworkError;
constructor(options) {
this._getEndpoint = this.createEndpointConfig(
options.getOptions != null ? options.getOptions : options
);
this._setEndpoint = this.createEndpointConfig(
options.setOptions != null ? options.setOptions : options
);
}
createEndpointConfig(options) {
const agentConfig = {
family: options.family,
keepAlive: true,
keepAliveMsecs: options.timeout || 5000,
maxSockets: 64,
maxFreeSockets: 64,
};
if (options.key != null) {
agentConfig.key = options.key;
}
if (options.cert != null) {
agentConfig.cert = options.cert;
}
if (options.ca != null) {
agentConfig.ca = options.ca;
}
const uri = url.parse(options.endpoint);
const module = uri.protocol === "http:" ? http : https;
if (!uri.hostname || !uri.pathname) {
throw new TypeError("Invalid endpoint: " + options.endpoint);
}
return {
headers: options.headers,
host: uri.hostname,
path: uri.pathname,
port: +uri.port,
agent: new module.Agent(agentConfig),
params: new URLSearchParams(options.params),
timeout: options.timeout || 5000,
module: uri.protocol === "http:" ? http : https,
additionalSuccessStatuses: new Set(
options.additionalSuccessStatuses ?? []
),
debug: options.debug ?? false,
maxAttempts: options.maxAttempts ?? 1,
retryStatuses: new Set(options.retryStatuses ?? []),
retryNetworkErrors: options.retryNetworkErrors ?? false,
};
}
get(key) {
return this.#withRetries(() => this.#getOnce(key), this._getEndpoint);
}
#getOnce(key) {
return new Promise((resolve, reject) => {
let searchParamsString = this._getEndpoint.params.toString();
if (searchParamsString != "") {
searchParamsString = "?" + searchParamsString;
}
const options = {
agent: this._getEndpoint.agent,
headers: this._getEndpoint.headers,
host: this._getEndpoint.host,
method: "GET",
path: `${this._getEndpoint.path}/${key.toString(
"hex"
)}${searchParamsString}`,
port: this._getEndpoint.port,
timeout: this._getEndpoint.timeout,
};
const req = this._getEndpoint.module.request(options, (res) => {
const code = res.statusCode;
const data = [];
if (code === 404) {
res.resume();
resolve(null);
return;
} else if (
code !== 200 &&
!this._getEndpoint.additionalSuccessStatuses.has(code)
) {
if (this._getEndpoint.debug) {
res.on("data", (chunk) => {
data.push(chunk);
});
res.on("error", (err) => {
reject(
new HttpError(
"Encountered network error (" +
err.message +
") while handling HTTP error: " +
code +
" " +
http.STATUS_CODES[code],
code
)
);
});
res.on("end", () => {
const buffer = Buffer.concat(data);
reject(
new HttpError(
"HTTP error: " +
code +
" " +
http.STATUS_CODES[code] +
"\n\n" +
buffer.toString(),
code
)
);
});
} else {
res.resume();
reject(
new HttpError(
"HTTP error: " + code + " " + http.STATUS_CODES[code],
code
)
);
}
return;
}
const gunzipped = res.pipe(zlib.createGunzip());
gunzipped.on("data", (chunk) => {
data.push(chunk);
});
gunzipped.on("error", (err) => {
reject(err);
});
gunzipped.on("end", () => {
try {
const buffer = Buffer.concat(data);
if (buffer.length > 0 && buffer[0] === NULL_BYTE) {
resolve(buffer.slice(1));
} else {
resolve(JSON.parse(buffer.toString("utf8")));
}
} catch (err) {
reject(err);
}
});
res.on("error", (err) => gunzipped.emit("error", err));
});
req.on("error", (err) => {
reject(new NetworkError(err.message, err.code));
});
req.on("timeout", () => {
req.destroy(new Error("Request timed out"));
});
req.end();
});
}
set(key, value) {
return this.#withRetries(
() => this.#setOnce(key, value),
this._setEndpoint
);
}
#setOnce(key, value) {
return new Promise((resolve, reject) => {
const gzip = zlib.createGzip(ZLIB_OPTIONS);
let searchParamsString = this._setEndpoint.params.toString();
if (searchParamsString != "") {
searchParamsString = "?" + searchParamsString;
}
const options = {
agent: this._setEndpoint.agent,
headers: this._setEndpoint.headers,
host: this._setEndpoint.host,
method: "PUT",
path: `${this._setEndpoint.path}/${key.toString(
"hex"
)}${searchParamsString}`,
port: this._setEndpoint.port,
timeout: this._setEndpoint.timeout,
};
const req = this._setEndpoint.module.request(options, (res) => {
const code = res.statusCode;
if (
(code < 200 || code > 299) &&
!this._setEndpoint.additionalSuccessStatuses.has(code)
) {
if (this._setEndpoint.debug) {
const data = [];
res.on("data", (chunk) => {
data.push(chunk);
});
res.on("error", (err) => {
reject(
new HttpError(
"Encountered network error (" +
err.message +
") while handling HTTP error: " +
code +
" " +
http.STATUS_CODES[code],
code
)
);
});
res.on("end", () => {
const buffer = Buffer.concat(data);
reject(
new HttpError(
"HTTP error: " +
code +
" " +
http.STATUS_CODES[code] +
"\n\n" +
buffer.toString(),
code
)
);
});
} else {
res.resume();
reject(
new HttpError(
"HTTP error: " + code + " " + http.STATUS_CODES[code],
code
)
);
}
return;
}
res.on("error", (err) => {
reject(err);
});
res.on("end", () => {
resolve();
});
res.resume();
});
req.on("timeout", () => {
req.destroy(new Error("Request timed out"));
});
gzip.pipe(req);
if (value instanceof Buffer) {
gzip.write(NULL_BYTE_BUFFER);
gzip.end(value);
} else {
gzip.end(JSON.stringify(value) || "null");
}
});
}
clear() {}
#withRetries(fn, endpoint) {
if (endpoint.maxAttempts === 1) {
return fn();
}
return backOff(fn, {
jitter: "full",
maxDelay: 30000,
numOfAttempts: this._getEndpoint.maxAttempts || Number.POSITIVE_INFINITY,
retry: (e) => {
if (e instanceof HttpError) {
return this._getEndpoint.retryStatuses.has(e.code);
}
return (
e instanceof NetworkError && this._getEndpoint.retryNetworkErrors
);
},
});
}
}
module.exports = HttpStore;

View File

@@ -0,0 +1,400 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
'use strict';
import type {Agent as HttpAgent} from 'http';
import type {Agent as HttpsAgent} from 'https';
const HttpError = require('./HttpError');
const NetworkError = require('./NetworkError');
const {backOff} = require('exponential-backoff');
const http = require('http');
const https = require('https');
const url = require('url');
const zlib = require('zlib');
export type Options =
| EndpointOptions // Uses the same options for both reads and writes
| {getOptions: EndpointOptions, setOptions: EndpointOptions}; // Uses different options for reads and writes
type EndpointOptions = {
endpoint: string,
family?: 4 | 6,
timeout?: number,
key?: string | $ReadOnlyArray<string> | Buffer | $ReadOnlyArray<Buffer>,
cert?: string | $ReadOnlyArray<string> | Buffer | $ReadOnlyArray<Buffer>,
ca?: string | $ReadOnlyArray<string> | Buffer | $ReadOnlyArray<Buffer>,
params?: URLSearchParams,
headers?: {[string]: string},
additionalSuccessStatuses?: $ReadOnlyArray<number>,
/**
* Whether to include additional debug information in error messages.
*/
debug?: boolean,
/**
* Retry configuration
*/
maxAttempts?: number,
retryNetworkErrors?: boolean,
retryStatuses?: $ReadOnlySet<number>,
};
type Endpoint = {
module: typeof http | typeof https,
host: string,
path: string,
port: number,
agent: HttpAgent | HttpsAgent,
params: URLSearchParams,
headers?: {[string]: string},
timeout: number,
additionalSuccessStatuses: $ReadOnlySet<number>,
debug: boolean,
/**
* Retry configuration
*/
maxAttempts: number,
retryNetworkErrors: boolean,
retryStatuses: $ReadOnlySet<number>,
};
const ZLIB_OPTIONS = {
level: 9,
};
const NULL_BYTE = 0x00;
const NULL_BYTE_BUFFER = Buffer.from([NULL_BYTE]);
class HttpStore<T> {
static HttpError: typeof HttpError = HttpError;
static NetworkError: typeof NetworkError = NetworkError;
_getEndpoint: Endpoint;
_setEndpoint: Endpoint;
constructor(options: Options) {
this._getEndpoint = this.createEndpointConfig(
options.getOptions != null ? options.getOptions : options,
);
this._setEndpoint = this.createEndpointConfig(
options.setOptions != null ? options.setOptions : options,
);
}
createEndpointConfig(options: EndpointOptions): Endpoint {
const agentConfig: http$agentOptions = {
family: options.family,
keepAlive: true,
keepAliveMsecs: options.timeout || 5000,
maxSockets: 64,
maxFreeSockets: 64,
};
if (options.key != null) {
// $FlowFixMe `key` is missing in the Flow definition
agentConfig.key = options.key;
}
if (options.cert != null) {
// $FlowFixMe `cert` is missing in the Flow definition
agentConfig.cert = options.cert;
}
if (options.ca != null) {
// $FlowFixMe `ca` is missing in the Flow definition
agentConfig.ca = options.ca;
}
const uri = url.parse(options.endpoint);
const module = uri.protocol === 'http:' ? http : https;
if (!uri.hostname || !uri.pathname) {
throw new TypeError('Invalid endpoint: ' + options.endpoint);
}
return {
headers: options.headers,
host: uri.hostname,
path: uri.pathname,
port: +uri.port,
agent: new module.Agent(agentConfig),
params: new URLSearchParams(options.params),
timeout: options.timeout || 5000,
module: uri.protocol === 'http:' ? http : https,
additionalSuccessStatuses: new Set(
options.additionalSuccessStatuses ?? [],
),
debug: options.debug ?? false,
maxAttempts: options.maxAttempts ?? 1,
retryStatuses: new Set(options.retryStatuses ?? []),
retryNetworkErrors: options.retryNetworkErrors ?? false,
};
}
get(key: Buffer): Promise<?T> {
return this.#withRetries(() => this.#getOnce(key), this._getEndpoint);
}
#getOnce(key: Buffer): Promise<?T> {
return new Promise((resolve, reject) => {
let searchParamsString = this._getEndpoint.params.toString();
if (searchParamsString != '') {
searchParamsString = '?' + searchParamsString;
}
const options = {
agent: this._getEndpoint.agent,
headers: this._getEndpoint.headers,
host: this._getEndpoint.host,
method: 'GET',
path: `${this._getEndpoint.path}/${key.toString(
'hex',
)}${searchParamsString}`,
port: this._getEndpoint.port,
timeout: this._getEndpoint.timeout,
};
/* $FlowFixMe(>=0.101.0 site=react_native_fb) This comment suppresses an
* error found when Flow v0.101 was deployed. To see the error, delete
* this comment and run Flow. */
const req = this._getEndpoint.module.request(options, res => {
const code = res.statusCode;
const data = [];
if (code === 404) {
res.resume();
resolve(null);
return;
} else if (
code !== 200 &&
!this._getEndpoint.additionalSuccessStatuses.has(code)
) {
if (this._getEndpoint.debug) {
res.on('data', chunk => {
data.push(chunk);
});
res.on('error', err => {
reject(
new HttpError(
'Encountered network error (' +
err.message +
') while handling HTTP error: ' +
code +
' ' +
http.STATUS_CODES[code],
code,
),
);
});
res.on('end', () => {
const buffer = Buffer.concat(data);
reject(
new HttpError(
'HTTP error: ' +
code +
' ' +
http.STATUS_CODES[code] +
'\n\n' +
buffer.toString(),
code,
),
);
});
} else {
res.resume();
reject(
new HttpError(
'HTTP error: ' + code + ' ' + http.STATUS_CODES[code],
code,
),
);
}
return;
}
const gunzipped = res.pipe(zlib.createGunzip());
gunzipped.on('data', chunk => {
data.push(chunk);
});
gunzipped.on('error', err => {
reject(err);
});
gunzipped.on('end', () => {
try {
const buffer = Buffer.concat(data);
if (buffer.length > 0 && buffer[0] === NULL_BYTE) {
resolve((buffer.slice(1): any));
} else {
resolve(JSON.parse(buffer.toString('utf8')));
}
} catch (err) {
reject(err);
}
});
res.on('error', err => gunzipped.emit('error', err));
});
req.on('error', err => {
reject(new NetworkError(err.message, err.code));
});
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
req.end();
});
}
set(key: Buffer, value: T): Promise<void> {
return this.#withRetries(
() => this.#setOnce(key, value),
this._setEndpoint,
);
}
#setOnce(key: Buffer, value: T): Promise<void> {
return new Promise((resolve, reject) => {
const gzip = zlib.createGzip(ZLIB_OPTIONS);
let searchParamsString = this._setEndpoint.params.toString();
if (searchParamsString != '') {
searchParamsString = '?' + searchParamsString;
}
const options = {
agent: this._setEndpoint.agent,
headers: this._setEndpoint.headers,
host: this._setEndpoint.host,
method: 'PUT',
path: `${this._setEndpoint.path}/${key.toString(
'hex',
)}${searchParamsString}`,
port: this._setEndpoint.port,
timeout: this._setEndpoint.timeout,
};
/* $FlowFixMe(>=0.101.0 site=react_native_fb) This comment suppresses an
* error found when Flow v0.101 was deployed. To see the error, delete
* this comment and run Flow. */
const req = this._setEndpoint.module.request(options, res => {
const code = res.statusCode;
if (
(code < 200 || code > 299) &&
!this._setEndpoint.additionalSuccessStatuses.has(code)
) {
if (this._setEndpoint.debug) {
const data = [];
res.on('data', chunk => {
data.push(chunk);
});
res.on('error', err => {
reject(
new HttpError(
'Encountered network error (' +
err.message +
') while handling HTTP error: ' +
code +
' ' +
http.STATUS_CODES[code],
code,
),
);
});
res.on('end', () => {
const buffer = Buffer.concat(data);
reject(
new HttpError(
'HTTP error: ' +
code +
' ' +
http.STATUS_CODES[code] +
'\n\n' +
buffer.toString(),
code,
),
);
});
} else {
res.resume();
reject(
new HttpError(
'HTTP error: ' + code + ' ' + http.STATUS_CODES[code],
code,
),
);
}
return;
}
res.on('error', err => {
reject(err);
});
res.on('end', () => {
resolve();
});
// Consume all the data from the response without processing it.
res.resume();
});
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
gzip.pipe(req);
if (value instanceof Buffer) {
gzip.write(NULL_BYTE_BUFFER);
gzip.end(value);
} else {
gzip.end(JSON.stringify(value) || 'null');
}
});
}
clear() {
// Not implemented.
}
#withRetries<R>(fn: () => Promise<R>, endpoint: Endpoint): Promise<R> {
if (endpoint.maxAttempts === 1) {
return fn();
}
return backOff(fn, {
jitter: 'full',
maxDelay: 30000,
numOfAttempts: this._getEndpoint.maxAttempts || Number.POSITIVE_INFINITY,
retry: (e: Error) => {
if (e instanceof HttpError) {
return this._getEndpoint.retryStatuses.has(e.code);
}
return (
e instanceof NetworkError && this._getEndpoint.retryNetworkErrors
);
},
});
}
}
module.exports = HttpStore;

View File

@@ -0,0 +1,9 @@
"use strict";
class NetworkError extends Error {
constructor(message, code) {
super(message);
this.code = code;
}
}
module.exports = NetworkError;

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow strict
*/
'use strict';
class NetworkError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
}
module.exports = NetworkError;

View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/
export interface CacheStore<T> {
get(key: Buffer): T | undefined | Promise<T> | Promise<undefined>;
set(key: Buffer, value: T): void | Promise<void>;
clear(): void | Promise<void>;
}

View File

@@ -0,0 +1 @@
"use strict";

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @format
* @oncall react_native
*/
'use strict';
export interface CacheStore<T> {
name?: string;
get(key: Buffer): ?T | Promise<?T>;
set(key: Buffer, value: T): void | Promise<void>;
clear(): void | Promise<void>;
}