@bencapp3/react-native-static-server
Version:
Embedded HTTP server for React Native
404 lines (379 loc) • 14.8 kB
JavaScript
"use strict";
import { AppState, NativeEventEmitter, Platform } from 'react-native';
import { copyFileAssets, DocumentDirectoryPath, mkdir, readDirAssets, unlink } from '@bencapp3/react-native-fs';
import { Emitter, Semaphore } from '@dr.pogodin/js-utils';
import { ERROR_LOG_FILE, newStandardConfigFile } from "./config.js";
import { SIGNALS, STATES } from "./constants.js";
import ReactNativeStaticServer from "./NativeReactNativeStaticServer.js";
import { resolveAssetsPath } from "./utils.js";
export { ERROR_LOG_FILE, UPLOADS_DIR, WORK_DIR } from "./config.js";
export { STATES, resolveAssetsPath };
// ID-to-StaticServer map for all potentially active server instances,
// used to route native events back to JS server objects.
const servers = {};
const nativeEventEmitter = new NativeEventEmitter(ReactNativeStaticServer);
const LOOPBACK_ADDRESS = '127.0.0.1';
nativeEventEmitter.addListener('RNStaticServer', ({
serverId,
event,
details
}) => {
const group = servers[serverId];
if (group) {
switch (event) {
case SIGNALS.CRASHED:
// TODO: We probably can, and should, capture the native stack trace
// and pass it along with the error.
const error = Error(details);
group.forEach(item => item._setState(STATES.CRASHED, details, error));
// TODO: Should we do here the following?
// delete servers[this._id];
break;
default:
throw Error(`Unexpected signal ${event}`);
}
}
});
// TODO: The idea for later implementation is to allow users to provide their
// own lighttpd config files with completely custom configuration. To do so,
// we'll probably split StaticServer class in two: the BaseServer will run
// server with given config path, and it will contain the logic like server
// state watching, stopInBackground, etc. The StaticServer class will extend it,
// allowing the current interface (starting the server with default config,
// and providing the most important config options via arguments), it will
// hold the props for accesing those options, selected hostname and port, etc.
// (as BaseServer will run the server on whatever port is given in provided
// config, but it won't be able to tell user what address / port it was, etc.).
class StaticServer {
// NOTE: could be a private method, and we tried it, but it turns out that
// Babel's @babel/plugin-proposal-private-methods causes many troubles in RN.
// See: https://github.com/birdofpreyru/react-native-static-server/issues/6
// and: https://github.com/birdofpreyru/react-native-static-server/issues/9
_hostname = '';
/* DEPRECATED */
_origin = '';
_stateChangeEmitter = new Emitter();
// TODO: It will be better to use UUID, but I believe "uuid" library
// I would use won't work in RN without additional workarounds applied
// to RN setup to get around some issues with randombytes support in
// RN JS engine. Anyway, it should be double-checked later, but using
// timestamps as ID will do for now.
// It is used to serialize state change requests, thus ensuring that parallel
// requests to start / stop the server won't result in a corrupt state.
_sem = new Semaphore(true);
get errorLog() {
return this._errorLog || false;
}
get fileDir() {
return this._fileDir;
}
get hostname() {
return this._hostname;
}
get id() {
return this._id;
}
/** @deprecated */
get nonLocal() {
return this._nonLocal;
}
get origin() {
return this._origin;
}
get stopInBackground() {
return this._stopInBackground;
}
get port() {
return this._port;
}
get state() {
return this._state;
}
_setState(neu, details = '', error) {
this._state = neu;
this._stateChangeEmitter.emit(neu, details, error);
}
/**
* Creates a new Server instance.
*/
constructor({
errorLog = false,
extraConfig = '',
fileDir,
hostname,
// NOTE: For some reasons RN-Windows corrupts large numbers sent
// as event arguments across JS / Native boundary.
// Everything smaller than 65535 seems to work fine, so let's just
// truncate these IDs for now.
// See: https://github.com/microsoft/react-native-windows/issues/11322
id = Date.now() % 65535,
/* DEPRECATED */nonLocal = false,
port = 0,
state = STATES.INACTIVE,
stopInBackground = false,
/* DEPRECATED */webdav
}) {
if (errorLog) this._errorLog = errorLog === true ? {} : errorLog;
this._extraConfig = extraConfig;
this._id = id;
this._nonLocal = nonLocal;
this._hostname = hostname || (nonLocal ? '' : LOOPBACK_ADDRESS);
this._port = port;
this._stopInBackground = stopInBackground;
this._state = state;
// NOTE: Normally, a server instance is connected to events from the native
// side inside its .start() call, and it is disconnected from them inside
// its .stop() call (or crash clean-up sequence). However, if the server is
// created with a claim that it is already active, we should connect it to
// the events rigth away here.
switch (state) {
case STATES.ACTIVE:
case STATES.STARTING:
{
this._registerSelf();
break;
}
default:
}
if (!fileDir) throw Error('`fileDir` MUST BE a non-empty string');
this._fileDir = resolveAssetsPath(fileDir);
this._webdav = webdav;
}
addStateListener(listener) {
return this._stateChangeEmitter.addListener(listener);
}
_configureAppStateHandling() {
if (this._stopInBackground) {
if (!this._appStateSub) {
this._appStateSub = AppState.addEventListener('change', this._handleAppStateChange.bind(this));
}
} else if (this._appStateSub) {
this._appStateSub.remove();
this._appStateSub = undefined;
}
}
async _removeConfigFile() {
if (this._configPath) {
const p = this._configPath;
// Resetting the field prior to the async unlink attempt is safer,
// in case the caller does not await for this method to complete.
this._configPath = undefined;
try {
await unlink(p);
} catch {
// IGNORE
}
}
}
_registerSelf() {
let group = servers[this._id];
if (group) group.add(this);else {
group = new Set([this]);
servers[this._id] = group;
}
}
/**
* This method throws if server is not in a "stable" state, i.e. not in one
* of these: ACTIVE, CRASHED, INACTIVE.
*/
_stableStateGuard() {
switch (this._state) {
case STATES.ACTIVE:
case STATES.CRASHED:
case STATES.INACTIVE:
return;
default:
throw Error(`Server is in unstable state ${this._state}`);
}
}
/**
* Removes all state listeners connected to this server instance.
*/
removeAllStateListeners() {
this._stateChangeEmitter.removeAllListeners();
}
/**
* Removes given state listener, if it is connected to this server instance;
* or does nothing if the listener is not connected to it.
* @param listener
*/
removeStateListener(listener) {
this._stateChangeEmitter.removeListener(listener);
}
/**
* @param {string} [details] Optional. If provided, it will be added
* to the STARTING message emitted to the server state change listeners.
* @returns {Promise<string>}
*/
async start(details) {
try {
await this._sem.seize();
this._stableStateGuard();
if (this._state === STATES.ACTIVE) return this._origin;
this._registerSelf();
this._setState(STATES.STARTING, details);
this._configureAppStateHandling();
// NOTE: This is done at the first start only, to avoid hostname changes
// when server is paused for background and automatically reactivated
// later. Same for automatic port selection.
if (!this._hostname) {
this._hostname = await ReactNativeStaticServer.getLocalIpAddress();
}
if (!this._port) {
this._port = await ReactNativeStaticServer.getOpenPort(this._hostname);
}
this._origin = `http://${this._hostname}:${this._port}`;
await this._removeConfigFile();
this._configPath = await newStandardConfigFile({
errorLog: this._errorLog,
extraConfig: this._extraConfig,
fileDir: this._fileDir,
hostname: this._hostname,
port: this._port,
webdav: this._webdav
});
// Native implementations of .start() method must resolve only once
// the server has been launched (ready to handle incoming requests).
await ReactNativeStaticServer.start(this._id, this._configPath, this._errorLog ? ERROR_LOG_FILE : '');
this._setState(STATES.ACTIVE);
this._removeConfigFile();
return this._origin;
} catch (e) {
const error = e instanceof Error ? e : Error(e.message, {
cause: e
});
this._setState(STATES.CRASHED, error.message, error);
throw error;
} finally {
this._sem.setReady(true);
}
}
/**
* Soft-stop the server: if automatic pause for background is enabled,
* it will be automatically reactivated when the app goes into foreground
* again. End users are expected to use public .stop() method below, which
* additionally cleans-up that automatic re-activation.
* @param {string} [details] Optional. If provided, it will be added
* to the STOPPING message emitted to the server state change listeners.
* @returns {Promise<>}
*/
async _stop(details) {
try {
await this._sem.seize();
this._stableStateGuard();
if (this._state !== STATES.ACTIVE) return;
this._setState(STATES.STOPPING, details);
// Native implementations of .stop() method must resolve only once
// the server has been completely shut down (released the port it listens).
await ReactNativeStaticServer.stop();
this._setState(STATES.INACTIVE);
} catch (e) {
const error = e instanceof Error ? e : Error(e.message, {
cause: e
});
this._setState(STATES.CRASHED, error.message, error);
throw error;
} finally {
const set = servers[this._id];
set?.delete(this);
if (!set?.size) delete servers[this._id];
this._sem.setReady(true);
}
}
/**
* Stops or pauses the server, if it is running, depending on whether it was
* started with keepAlive option or not (note, keepAlive means that server is
* not paused / restarted when the app goes into background). Pausing the
* server means it will
* automatically start again the next time the app transitions from background
* to foreground. To ensure the server is stopped for good, pass in `kill`
* flag. In that case only explicit call to .start() will start the server
* again.
* @param {string} [details] Optional. If provided, it will be added
* to the STOPPING message emitted to the server state change listeners.
* @returns {Promise<>}
*/
async stop(details) {
if (this._appStateSub) {
this._appStateSub.remove();
this._appStateSub = undefined;
}
await this._stop(details);
}
async _handleAppStateChange(appState) {
const starting = appState === 'active' || appState === 'inactive';
try {
if (starting) await this.start('App entered foreground');else await this._stop('App entered background');
} catch (e) {
// If anything goes wrong within .start() or ._stop() calls, those methods
// will move the server into the "CRASHED" state, and they'll notify all
// server state listeners (see .addStateListener()) about the error, with
// all related details.
//
// Thus, if any state listener is connected to the server, we assume it
// handles possible errors, and we just silently ignore the error here
// (otherwise some instrumentation, like e.g. Sentry, may catch and report
// it as unhandled, which is confusing when the error has been handled
// within the listener).
//
// However, if no listeners are connected, we throw an error to allow
// the instrumentation, if any, to detect and report it as unhandled.
//
// In either case this very function is used internally, thus no way for
// the library consumer to directly handle its possible rejections.
if (!this._stateChangeEmitter.hasListeners) {
throw Error(starting ? `Server (#${this._id}) auto-start on the app going into foreground failed` : `Server (#${this._id}) auto-stop on the app going into background failed`);
}
}
}
}
export default StaticServer;
/**
* Extracts bundled assets into the specified regular directory,
* preserving asset folder structure, and overwriting any conflicting files
* in the destination.
*
* This is an Android-specific function; it does nothing if called on iOS.
*
* @param {string} [into=''] Optional. The destination folder for extracted
* assets. By default assets are extracted into the app's document folder.
* @param {string} [from=''] Optional. Relative path of the root asset folder,
* starting from which all assets contained in that folder and its subfolders
* will be extracted into the destination folder, preserving asset folder
* structure. By default all bundled assets will be extracted.
* @return {Promise} Resolves once unpacking is completed.
*/
export async function extractBundledAssets(into = DocumentDirectoryPath, from = '') {
console.warn('extractBundledAssets() is deprecated! See: https://github.com/birdofpreyru/react-native-static-server?tab=readme-ov-file#extractbundledassets');
if (Platform.OS !== 'android') return;
await mkdir(into);
const assets = await readDirAssets(from);
for (let i = 0; i < assets.length; ++i) {
const asset = assets[i];
const target = `${into}/${asset.name}`;
if (asset.isDirectory()) await extractBundledAssets(target, asset.path);else await copyFileAssets(asset.path, target);
}
}
/**
* Returns a set of server instance currently being in ACTIVE, STARTING,
* or STOPPING state, if such server instances exists.
* @return {Set<StaticServer>|undefined}
*/
export function getActiveServerSet() {
return Object.values(servers).find(group => {
const server = group.values().next().value;
const state = server?.state;
return state !== STATES.INACTIVE && state !== STATES.CRASHED;
});
}
/**
* Returns a server instance currently being in ACTIVE, STARTING,
* or STOPPING state, if such server instance exists.
* @return {StaticServer|undefined}
*/
export function getActiveServer() {
const set = getActiveServerSet();
return set && set.values().next().value;
}
export const getActiveServerId = ReactNativeStaticServer.getActiveServerId;
//# sourceMappingURL=index.js.map