diff --git a/packages/layouts/package.json b/packages/layouts/package.json index 01b8043..4ca4689 100644 --- a/packages/layouts/package.json +++ b/packages/layouts/package.json @@ -4,6 +4,7 @@ "main": "./lib/index.js", "license": "MIT", "dependencies": { + "comlink": "^4.3.0", "d3": "^5.10.0", "lodash": "^4.17.15" }, @@ -39,6 +40,7 @@ }, "devDependencies": { "@types/d3": "^5.7.2", - "@types/lodash": "^4.14.137" + "@types/lodash": "^4.14.137", + "@types/node": "^14.11.2" } } diff --git a/packages/layouts/src/ForceSimulation.ts b/packages/layouts/src/ForceSimulation.ts index 9ab36b9..6e7bc35 100644 --- a/packages/layouts/src/ForceSimulation.ts +++ b/packages/layouts/src/ForceSimulation.ts @@ -1,436 +1,40 @@ -import * as d3 from 'd3' -import {meanBy, noop, defaults} from 'lodash' -import {validateForceConfig, validateSimulationData} from './validators' -import Ajv from 'ajv' +import { + FORCE_DEFAULTS, + ForceConfig, + ForceSimulationBase, + NodePosition, + SimulationData, +} from './ForceSimulationBase' +import {noop} from 'lodash' -export interface SimulationNode extends d3.SimulationNodeDatum { - /** - * unique id for the node - */ - id: string - - /** - * array of group ids the node belongs to - */ - displayGroupIds?: string[] - - /** - * d3 forceX seed value - * @see https://github.com/d3/d3-force#forceX - */ - forceX?: number - - /** - * d3 forceY seed value - * @see https://github.com/d3/d3-force#forceY - */ - forceY?: number - - /** - * d3 force manyBody strength - * @see https://github.com/d3/d3-force#forceManyBody - * @default -30 - * @maximum 0 - */ - charge?: number -} - -export interface SimulationLink { - /** - * id of source node - */ - source: string - - /** - * id of target node - */ - target: string - - /** - * Multiplicative factor applied to default d3 link force, - * which serves as an attractive force between the endpoints. - * A value between 0 and 1 will reduce the attractive force, - * tending to increase the length of the link. - * @see https://github.com/d3/d3-force#link_strength - * @default 1 - * @minimum 0 - * @maximum 1 - */ - strengthMultiplier?: number -} - -export interface SimulationGroup { - /** - * id of the group - */ - id: string - - /** - * attractive strength of the nodes in this group - * @default 0 - */ - strength?: number -} - -export interface SimulationData { - /** - * list of simulation nodes - */ - nodes: SimulationNode[] - - /** - * list of simulation links - */ - links: SimulationLink[] - - /** - * list of forceGroups - */ - forceGroups: SimulationGroup[] -} - -type D3Simulation = d3.Simulation - -export interface NodePosition { - id: string - x: number - y: number - z?: number -} - -function forceGroup(groups: SimulationGroup[], defaultStrength: number) { - let nodes: SimulationNode[] - let nodesByGroup: {[groupId: string]: SimulationNode[]} - let strength = defaultStrength - - function force(alpha: number) { - groups.forEach(group => { - if (!nodesByGroup[group.id]) { - // this will happen for archived clusters - return - } - - const groupNodes = nodesByGroup[group.id] - - const {x: cx, y: cy} = getCentroid(groupNodes) - const l = alpha * (group.strength ?? strength) - - groupNodes.forEach(node => { - node.vx! -= (node.x! - cx) * l - node.vy! -= (node.y! - cy) * l - }) - }) - } - - // this function is automatically called by d3 on initialize - force.initialize = (data: SimulationNode[]) => { - nodes = data - nodesByGroup = getGroupedNodes(nodes) - return nodes - } - - force.strength = (value: number) => { - strength = value - } - - return force -} - -function getCentroid(points: SimulationNode[]): {x: number; y: number} { - return { - x: meanBy(points, p => p.x), - y: meanBy(points, p => p.y), +export class ForceSimulation extends ForceSimulationBase { + constructor() { + super() } -} - -function getGroupedNodes( - nodes: SimulationNode[], -): {[groupId: string]: SimulationNode[]} { - const nodesByGroup: {[groupId: string]: SimulationNode[]} = {} - nodes.forEach((n, i) => { - if (n.displayGroupIds) { - n.displayGroupIds.forEach(groupId => { - if (!nodesByGroup[groupId]) { - nodesByGroup[groupId] = [] - } - - nodesByGroup[groupId].push(n) - }) - } - }) - return nodesByGroup -} - -/** - * Based on the default implementation - * @see https://github.com/d3/d3-force#link_strength - */ -function getDefaultLinkForceStrengths(links: SimulationLink[]): number[] { - const counts: {[id: string]: number} = {} - - links.forEach((l, i) => { - if (!counts[l.source]) { - counts[l.source] = 0 - } - counts[l.source]++ - if (!counts[l.target]) { - counts[l.target] = 0 - } - counts[l.target]++ - }) - - return links.map(link => { - return 1.0 / Math.min(counts[link.source], counts[link.target]) - }) -} + thread = 'main' as const -export interface ForceConfig { - /** - * charge on each node - * @default -30 - * @maximum 0 - */ - nodeCharge?: number - - /** - * strengthMultiplier for each link - * - * @default 1 - * @minimum 0 - * @maximum 1 - */ - linkStrengthMultiplier?: number - - /** - * strength for each group - * - * @default 0 - */ - groupStrength?: number -} -export const FORCE_DEFAULTS = { - nodeCharge: -30, - linkStrengthMultiplier: 1, - groupStrength: 0, -} - -export class ForceSimulation { - public simulation: D3Simulation | undefined - public staticMode = false - private registeredEventHandlers: { + // narrow down type def + registeredEventHandlers: { tick: (nodePositions: NodePosition[]) => void } = { - // Default no-op implementation. Will be overwritten by the callback provided to onSimulationTick tick: noop, } - // this is tracked referentially so readonly is important here - private readonly config: Required = FORCE_DEFAULTS - - private defaultLinkForceStrengths: number[] = [] public initialize( graph: SimulationData, config: ForceConfig = FORCE_DEFAULTS, - staticMode = false, ) { - if (!validateSimulationData(graph)) { - const err = validateSimulationData.errors?.[0] as Ajv.ErrorObject - throw new Error(`data${err.dataPath} ${err.message}`) - } - - // reset undefined values to default values - const mergedConfig = defaults({}, config, FORCE_DEFAULTS) - Object.assign(this.config, mergedConfig) // preserve referential equality - - this.staticMode = staticMode - const nodesCopy = graph.nodes.map(node => ({...node})) - const linksCopy = graph.links.map(link => ({...link})) - const groupsCopy = graph.forceGroups.map(group => ({...group})) - - this.defaultLinkForceStrengths = getDefaultLinkForceStrengths(linksCopy) - - // stop any previous simulation before reinitializing to prevent - // zombie tick events + super.initialize(graph, config) if (this.simulation) { - this.simulation.stop() - } - - this.simulation = d3 - .forceSimulation(nodesCopy) - .force( - 'x', - d3 - .forceX() - .strength((node: SimulationNode) => - node.forceX !== undefined ? 0.1 : 0, - ) - .x((node: SimulationNode) => node.forceX ?? 0), - ) - .force( - 'y', - d3 - .forceY() - .strength((node: SimulationNode) => - node.forceY !== undefined ? 0.1 : 0, - ) - .y((node: SimulationNode) => node.forceY ?? 0), - ) - .force( - 'links', - d3 - .forceLink(linksCopy) - .strength( - (link, i) => - this.defaultLinkForceStrengths[i] * - (link.strengthMultiplier ?? this.config.linkStrengthMultiplier), - ) - .id((n: SimulationNode) => n.id), - ) - .force( - 'charge', - d3 - .forceManyBody() - .strength((n: SimulationNode) => - n.charge !== undefined ? n.charge : this.config.nodeCharge, - ), - ) - .force('group', forceGroup(groupsCopy, this.config.groupStrength)) - .on('tick', () => { + this.simulation.on('tick', () => { this.registeredEventHandlers.tick(this.getNodePositions()) }) - - if (staticMode) { - this.simulation.stop() - this.execManualTicks() - } - } - - private execManualTicks() { - if (this.simulation) { - for (let i = 0, n = 300; i < n; ++i) { - this.simulation.tick() - } - } - } - - public getNodePositions(): NodePosition[] { - if (!this.simulation) { - return [] } - const nodes = this.simulation.nodes() as SimulationNode[] - - return nodes.map(node => ({ - id: node.id, - x: node.x ?? 0, - y: node.y ?? 0, - })) } + // narrow down type def public onTick(callback: (nodePositions: NodePosition[]) => void) { - this.registeredEventHandlers.tick = callback - } - - /** - * update config and force simulation as needed - * @param newConfig - */ - public updateConfig(newConfig?: ForceConfig) { - if (!validateForceConfig(newConfig)) { - const err = validateForceConfig.errors?.[0] as Ajv.ErrorObject - throw new Error(`config${err.dataPath} ${err.message}`) - } - if (!this.simulation) { - return - } - const mergedConfig = defaults({}, newConfig, FORCE_DEFAULTS) - - if (this.config.nodeCharge !== mergedConfig.nodeCharge) { - // NOTE: preserve reference for this.config !important - this.config.nodeCharge = mergedConfig.nodeCharge - const forceMB = this.simulation.force('charge') as d3.ForceManyBody< - SimulationNode - > - forceMB.strength((n: SimulationNode) => - n.charge !== undefined ? n.charge : this.config.nodeCharge, - ) - } - - if ( - this.config.linkStrengthMultiplier !== mergedConfig.linkStrengthMultiplier - ) { - this.config.linkStrengthMultiplier = mergedConfig.linkStrengthMultiplier - const forceLink = this.simulation.force('links') as d3.ForceLink< - SimulationNode, - SimulationLink - > - forceLink.strength( - (link, i) => - this.defaultLinkForceStrengths[i] * - (link.strengthMultiplier ?? this.config.linkStrengthMultiplier), - ) - } - - if (this.config.groupStrength !== mergedConfig.groupStrength) { - this.config.groupStrength = mergedConfig.groupStrength - const force = this.simulation.force('group') as ReturnType< - typeof forceGroup - > - force.strength(this.config.groupStrength) - } - - this.simulation.alpha(0.1).restart() - } - - public update(graph: SimulationData) { - if (!this.simulation) { - return - } - - const nodesCopy = graph.nodes.map(node => ({...node})) - const linksCopy = graph.links.map(link => ({...link})) - - this.simulation.nodes(nodesCopy).force( - 'links', - d3.forceLink(linksCopy).id((n: SimulationNode) => n.id), - ) - // if (this.staticMode) { - // this.execManualTicks() - // } - } - - public restart() { - if (!this.simulation) { - return - } - // if (this.staticMode) { - // this.execManualTicks() - // } - this.simulation.alpha(1) - this.simulation.restart() - } - - public reheat() { - if (!this.simulation) { - return - } - // if (this.staticMode) { - // this.execManualTicks() - // } - this.simulation.alphaTarget(0.8).restart() - } - - public settle() { - if (!this.simulation) { - return - } - this.simulation.alphaTarget(0) - } - - public stop() { - if (!this.simulation) { - return - } - this.simulation.stop() + super.onTick(callback) } } diff --git a/packages/layouts/src/ForceSimulationBase.ts b/packages/layouts/src/ForceSimulationBase.ts new file mode 100644 index 0000000..25e7934 --- /dev/null +++ b/packages/layouts/src/ForceSimulationBase.ts @@ -0,0 +1,427 @@ +import * as d3 from 'd3' +import {defaults, meanBy, noop} from 'lodash' +import {validateForceConfig, validateSimulationData} from './validators' +import Ajv from 'ajv' + +export interface SimulationNode extends d3.SimulationNodeDatum { + /** + * unique id for the node + */ + id: string + + /** + * array of group ids the node belongs to + */ + displayGroupIds?: string[] + + /** + * d3 forceX seed value + * @see https://github.com/d3/d3-force#forceX + */ + forceX?: number + + /** + * d3 forceY seed value + * @see https://github.com/d3/d3-force#forceY + */ + forceY?: number + + /** + * d3 force manyBody strength + * @see https://github.com/d3/d3-force#forceManyBody + * @default -30 + * @maximum 0 + */ + charge?: number +} + +export interface SimulationLink { + /** + * id of source node + */ + source: string + + /** + * id of target node + */ + target: string + + /** + * Multiplicative factor applied to default d3 link force, + * which serves as an attractive force between the endpoints. + * A value between 0 and 1 will reduce the attractive force, + * tending to increase the length of the link. + * @see https://github.com/d3/d3-force#link_strength + * @default 1 + * @minimum 0 + * @maximum 1 + */ + strengthMultiplier?: number +} + +export interface SimulationGroup { + /** + * id of the group + */ + id: string + + /** + * attractive strength of the nodes in this group + * @default 0 + */ + strength?: number +} + +export interface SimulationData { + /** + * list of simulation nodes + */ + nodes: SimulationNode[] + + /** + * list of simulation links + */ + links: SimulationLink[] + + /** + * list of forceGroups + */ + forceGroups: SimulationGroup[] +} + +type D3Simulation = d3.Simulation + +export interface NodePosition { + id: string + x: number + y: number + z?: number +} + +function forceGroup(groups: SimulationGroup[], defaultStrength: number) { + let nodes: SimulationNode[] + let nodesByGroup: {[groupId: string]: SimulationNode[]} + let strength = defaultStrength + + function force(alpha: number) { + groups.forEach(group => { + if (!nodesByGroup[group.id]) { + // this will happen for archived clusters + return + } + + const groupNodes = nodesByGroup[group.id] + + const {x: cx, y: cy} = getCentroid(groupNodes) + const l = alpha * (group.strength ?? strength) + + groupNodes.forEach(node => { + node.vx! -= (node.x! - cx) * l + node.vy! -= (node.y! - cy) * l + }) + }) + } + + // this function is automatically called by d3 on initialize + force.initialize = (data: SimulationNode[]) => { + nodes = data + nodesByGroup = getGroupedNodes(nodes) + return nodes + } + + force.strength = (value: number) => { + strength = value + } + + return force +} + +function getCentroid(points: SimulationNode[]): {x: number; y: number} { + return { + x: meanBy(points, p => p.x), + y: meanBy(points, p => p.y), + } +} + +function getGroupedNodes( + nodes: SimulationNode[], +): {[groupId: string]: SimulationNode[]} { + const nodesByGroup: {[groupId: string]: SimulationNode[]} = {} + nodes.forEach((n, i) => { + if (n.displayGroupIds) { + n.displayGroupIds.forEach(groupId => { + if (!nodesByGroup[groupId]) { + nodesByGroup[groupId] = [] + } + + nodesByGroup[groupId].push(n) + }) + } + }) + return nodesByGroup +} + +/** + * Based on the default implementation + * @see https://github.com/d3/d3-force#link_strength + */ +function getDefaultLinkForceStrengths(links: SimulationLink[]): number[] { + const counts: {[id: string]: number} = {} + + links.forEach((l, i) => { + if (!counts[l.source]) { + counts[l.source] = 0 + } + counts[l.source]++ + + if (!counts[l.target]) { + counts[l.target] = 0 + } + counts[l.target]++ + }) + + return links.map(link => { + return 1.0 / Math.min(counts[link.source], counts[link.target]) + }) +} + +export interface ForceConfig { + /** + * charge on each node + * @default -30 + * @maximum 0 + */ + nodeCharge?: number + + /** + * strengthMultiplier for each link + * + * @default 1 + * @minimum 0 + * @maximum 1 + */ + linkStrengthMultiplier?: number + + /** + * strength for each group + * + * @default 0 + */ + groupStrength?: number +} +export const FORCE_DEFAULTS = { + nodeCharge: -30, + linkStrengthMultiplier: 1, + groupStrength: 0, +} + +export class ForceSimulationBase { + public thread: 'main' | 'worker' + + protected simulation: D3Simulation | undefined + + protected registeredEventHandlers: { + tick: + | ((nodePositions: NodePosition[]) => void) + | ((progress: number) => void) + stabilized?: (nodePositions: NodePosition[]) => void + } = { + // Default no-op implementations + tick: noop, + } + // this is tracked referentially so readonly is important here + protected readonly config: Required = FORCE_DEFAULTS + + protected defaultLinkForceStrengths: number[] = [] + + public initialize( + graph: SimulationData, + config: ForceConfig = FORCE_DEFAULTS, + ) { + if (!validateSimulationData(graph)) { + const err = validateSimulationData.errors?.[0] as Ajv.ErrorObject + throw new Error(`data${err.dataPath} ${err.message}`) + } + + // reset undefined values to default values + const mergedConfig = defaults({}, config, FORCE_DEFAULTS) + Object.assign(this.config, mergedConfig) // preserve referential equality + + const nodesCopy = graph.nodes.map(node => ({...node})) + const linksCopy = graph.links.map(link => ({...link})) + const groupsCopy = graph.forceGroups.map(group => ({...group})) + + this.defaultLinkForceStrengths = getDefaultLinkForceStrengths(linksCopy) + + // stop any previous simulation before reinitializing to prevent + // zombie tick events + if (this.simulation) { + this.simulation.stop() + } + + this.simulation = d3 + .forceSimulation(nodesCopy) + .force( + 'x', + d3 + .forceX() + .strength((node: SimulationNode) => + node.forceX !== undefined ? 0.1 : 0, + ) + .x((node: SimulationNode) => node.forceX ?? 0), + ) + .force( + 'y', + d3 + .forceY() + .strength((node: SimulationNode) => + node.forceY !== undefined ? 0.1 : 0, + ) + .y((node: SimulationNode) => node.forceY ?? 0), + ) + .force( + 'links', + d3 + .forceLink(linksCopy) + .strength( + (link, i) => + this.defaultLinkForceStrengths[i] * + (link.strengthMultiplier ?? this.config.linkStrengthMultiplier), + ) + .id((n: SimulationNode) => n.id), + ) + .force( + 'charge', + d3 + .forceManyBody() + .strength((n: SimulationNode) => + n.charge !== undefined ? n.charge : this.config.nodeCharge, + ), + ) + .force('group', forceGroup(groupsCopy, this.config.groupStrength)) + } + + public getNodePositions(): NodePosition[] { + if (!this.simulation) { + return [] + } + const nodes = this.simulation.nodes() as SimulationNode[] + + return nodes.map(node => ({ + id: node.id, + x: node.x ?? 0, + y: node.y ?? 0, + })) + } + + public onTick( + callback: + | ((nodePositions: NodePosition[]) => void) + | ((progress: number) => void), + ) { + this.registeredEventHandlers.tick = callback + } + + /** + * update config and force simulation as needed + * @param newConfig + */ + public updateConfig(newConfig?: ForceConfig) { + if (!validateForceConfig(newConfig)) { + const err = validateForceConfig.errors?.[0] as Ajv.ErrorObject + throw new Error(`config${err.dataPath} ${err.message}`) + } + if (!this.simulation) { + return + } + const mergedConfig = defaults({}, newConfig, FORCE_DEFAULTS) + + if (this.config.nodeCharge !== mergedConfig.nodeCharge) { + // NOTE: preserve reference for this.config !important + this.config.nodeCharge = mergedConfig.nodeCharge + const forceMB = this.simulation.force('charge') as d3.ForceManyBody< + SimulationNode + > + forceMB.strength((n: SimulationNode) => + n.charge !== undefined ? n.charge : this.config.nodeCharge, + ) + } + + if ( + this.config.linkStrengthMultiplier !== mergedConfig.linkStrengthMultiplier + ) { + this.config.linkStrengthMultiplier = mergedConfig.linkStrengthMultiplier + const forceLink = this.simulation.force('links') as d3.ForceLink< + SimulationNode, + SimulationLink + > + forceLink.strength( + (link, i) => + this.defaultLinkForceStrengths[i] * + (link.strengthMultiplier ?? this.config.linkStrengthMultiplier), + ) + } + + if (this.config.groupStrength !== mergedConfig.groupStrength) { + this.config.groupStrength = mergedConfig.groupStrength + const force = this.simulation.force('group') as ReturnType< + typeof forceGroup + > + force.strength(this.config.groupStrength) + } + + this.simulation.alpha(0.1).restart() + } + + public update(graph: SimulationData) { + if (!this.simulation) { + return + } + + const nodesCopy = graph.nodes.map(node => ({...node})) + const linksCopy = graph.links.map(link => ({...link})) + + this.simulation.nodes(nodesCopy).force( + 'links', + d3.forceLink(linksCopy).id((n: SimulationNode) => n.id), + ) + // if (this.staticMode) { + // this.execManualTicks() + // } + } + + public restart() { + if (!this.simulation) { + return + } + // if (this.staticMode) { + // this.execManualTicks() + // } + this.simulation.alpha(1) + this.simulation.restart() + } + + public reheat() { + if (!this.simulation) { + return + } + // if (this.staticMode) { + // this.execManualTicks() + // } + this.simulation.alphaTarget(0.8).restart() + } + + public settle() { + if (!this.simulation) { + return + } + this.simulation.alphaTarget(0) + } + + public stop() { + if (!this.simulation) { + return + } + this.simulation.stop() + } +} diff --git a/packages/layouts/src/ForceSimulationWorker.ts b/packages/layouts/src/ForceSimulationWorker.ts new file mode 100644 index 0000000..66d84de --- /dev/null +++ b/packages/layouts/src/ForceSimulationWorker.ts @@ -0,0 +1,67 @@ +import { + FORCE_DEFAULTS, + ForceConfig, + ForceSimulationBase, + NodePosition, + SimulationData, +} from './ForceSimulationBase' +import {noop} from 'lodash' + +export class ForceSimulationWorker extends ForceSimulationBase { + constructor() { + super() + } + + thread = 'worker' as const + + // narrow down type def + registeredEventHandlers: { + tick: (progress: number) => void + stabilized: (nodePositions: NodePosition[]) => void + } = { + tick: noop, + stabilized: noop, + } + + public initialize( + graph: SimulationData, + config: ForceConfig = FORCE_DEFAULTS, + ) { + super.initialize(graph, config) + if (this.simulation) { + // stop default simulation start + this.simulation.stop() + + // start manual stabilization + this.start() + } + } + + // narrow down type def + public onTick(callback: (progress: number) => void) { + super.onTick(callback) + } + + public onStabilize(callback: (nodePositions: NodePosition[]) => void) { + this.registeredEventHandlers.stabilized = callback + } + + private start() { + if (this.simulation) { + for ( + let i = 0, + n = Math.ceil( + Math.log(this.simulation.alphaMin()) / + Math.log(1 - this.simulation.alphaDecay()), + ); + i < n; + ++i + ) { + this.registeredEventHandlers.tick(Math.round((i * 10000) / n) / 100) + this.simulation.tick() + } + this.registeredEventHandlers.tick(100) + this.registeredEventHandlers.stabilized(this.getNodePositions()) + } + } +} diff --git a/packages/layouts/src/index.ts b/packages/layouts/src/index.ts index cc644f6..3695d78 100644 --- a/packages/layouts/src/index.ts +++ b/packages/layouts/src/index.ts @@ -1,9 +1,11 @@ export { - ForceSimulation, NodePosition, SimulationNode, SimulationLink, SimulationGroup, SimulationData, ForceConfig, -} from './ForceSimulation' + FORCE_DEFAULTS, +} from './ForceSimulationBase' +export {ForceSimulation} from './ForceSimulation' +export {ForceSimulationWorker} from './ForceSimulationWorker' diff --git a/packages/layouts/src/worker.ts b/packages/layouts/src/worker.ts new file mode 100644 index 0000000..60c9422 --- /dev/null +++ b/packages/layouts/src/worker.ts @@ -0,0 +1,4 @@ +import * as Comlink from 'comlink' +import {ForceSimulationWorker} from './ForceSimulationWorker' + +Comlink.expose(ForceSimulationWorker) diff --git a/packages/react-sandbox/config/env.js b/packages/react-sandbox/config/env.js new file mode 100644 index 0000000..09ec03c --- /dev/null +++ b/packages/react-sandbox/config/env.js @@ -0,0 +1,101 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve('./paths')]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error( + 'The NODE_ENV environment variable is required but was not specified.' + ); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +const dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== 'test' && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require('dotenv-expand')( + require('dotenv').config({ + path: dotenvFile, + }) + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || '') + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || 'development', + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + // We support configuring the sockjs pathname during development. + // These settings let a developer run multiple simultaneous projects. + // They are used as the connection `hostname`, `pathname` and `port` + // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` + // and `sockPort` options in webpack-dev-server. + WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, + WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, + WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, + } + ); + // Stringify all values so we can feed into webpack DefinePlugin + const stringified = { + 'process.env': Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/packages/react-sandbox/config/getHttpsConfig.js b/packages/react-sandbox/config/getHttpsConfig.js new file mode 100644 index 0000000..013d493 --- /dev/null +++ b/packages/react-sandbox/config/getHttpsConfig.js @@ -0,0 +1,66 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const chalk = require('react-dev-utils/chalk'); +const paths = require('./paths'); + +// Ensure the certificate and key provided are valid and if not +// throw an easy to debug error +function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { + let encrypted; + try { + // publicEncrypt will throw an error with an invalid cert + encrypted = crypto.publicEncrypt(cert, Buffer.from('test')); + } catch (err) { + throw new Error( + `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}` + ); + } + + try { + // privateDecrypt will throw an error with an invalid key + crypto.privateDecrypt(key, encrypted); + } catch (err) { + throw new Error( + `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${ + err.message + }` + ); + } +} + +// Read file and throw an error if it doesn't exist +function readEnvFile(file, type) { + if (!fs.existsSync(file)) { + throw new Error( + `You specified ${chalk.cyan( + type + )} in your env, but the file "${chalk.yellow(file)}" can't be found.` + ); + } + return fs.readFileSync(file); +} + +// Get the https config +// Return cert files if provided in env, otherwise just true or false +function getHttpsConfig() { + const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; + const isHttps = HTTPS === 'true'; + + if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { + const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); + const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); + const config = { + cert: readEnvFile(crtFile, 'SSL_CRT_FILE'), + key: readEnvFile(keyFile, 'SSL_KEY_FILE'), + }; + + validateKeyAndCerts({ ...config, keyFile, crtFile }); + return config; + } + return isHttps; +} + +module.exports = getHttpsConfig; diff --git a/packages/react-sandbox/config/jest/cssTransform.js b/packages/react-sandbox/config/jest/cssTransform.js new file mode 100644 index 0000000..8f65114 --- /dev/null +++ b/packages/react-sandbox/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return 'module.exports = {};'; + }, + getCacheKey() { + // The output is always the same. + return 'cssTransform'; + }, +}; diff --git a/packages/react-sandbox/config/jest/fileTransform.js b/packages/react-sandbox/config/jest/fileTransform.js new file mode 100644 index 0000000..aab6761 --- /dev/null +++ b/packages/react-sandbox/config/jest/fileTransform.js @@ -0,0 +1,40 @@ +'use strict'; + +const path = require('path'); +const camelcase = require('camelcase'); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.svg$/)) { + // Based on how SVGR generates a component name: + // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 + const pascalCaseFilename = camelcase(path.parse(filename).name, { + pascalCase: true, + }); + const componentName = `Svg${pascalCaseFilename}`; + return `const React = require('react'); + module.exports = { + __esModule: true, + default: ${assetFilename}, + ReactComponent: React.forwardRef(function ${componentName}(props, ref) { + return { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }; + }), + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/packages/react-sandbox/config/modules.js b/packages/react-sandbox/config/modules.js new file mode 100644 index 0000000..c8efd0d --- /dev/null +++ b/packages/react-sandbox/config/modules.js @@ -0,0 +1,141 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); +const chalk = require('react-dev-utils/chalk'); +const resolve = require('resolve'); + +/** + * Get additional module paths based on the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + // If there's no baseUrl set we respect NODE_PATH + // Note that NODE_PATH is deprecated and will be removed + // in the next major release of create-react-app. + + const nodePath = process.env.NODE_PATH || ''; + return nodePath.split(path.delimiter).filter(Boolean); + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === '') { + return [paths.appSrc]; + } + + // If the path is equal to the root directory we ignore it here. + // We don't want to allow importing from the root directly as source files are + // not transpiled outside of `src`. We do allow importing them with the + // absolute path (e.g. `src/Components/Button.js`) but we set that up with + // an alias. + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return null; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + ' Create React App does not support other values at this time.' + ) + ); +} + +/** + * Get webpack aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getWebpackAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + src: paths.appSrc, + }; + } +} + +/** + * Get jest aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getJestAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + '^src/(.*)$': '/src/$1', + }; + } +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error( + 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + const ts = require(resolve.sync('typescript', { + basedir: paths.appNodeModules, + })); + config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + webpackAliases: getWebpackAliases(options), + jestAliases: getJestAliases(options), + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/packages/react-sandbox/config/paths.js b/packages/react-sandbox/config/paths.js new file mode 100644 index 0000000..b3fd764 --- /dev/null +++ b/packages/react-sandbox/config/paths.js @@ -0,0 +1,72 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = relativePath => path.resolve(appDirectory, relativePath); + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// webpack needs to know it to put the right