feat: add Filesystem util and load env util (#7487)
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { GracefulShutdownServer } from "../graceful-shutdown-server"
|
||||
|
||||
describe("GracefulShutdownServer", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllTimers()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {})
|
||||
|
||||
it('should add "isShuttingDown" property to the existing server', () => {
|
||||
const server = GracefulShutdownServer.create({ on: jest.fn() } as any)
|
||||
expect(server).toHaveProperty("isShuttingDown")
|
||||
expect(server.isShuttingDown).toEqual(false)
|
||||
})
|
||||
|
||||
it("should listen for client connections and store reference to them", async () => {
|
||||
const onEventMock = jest.fn()
|
||||
|
||||
GracefulShutdownServer.create({ on: onEventMock } as any)
|
||||
|
||||
expect(onEventMock).toBeCalledTimes(3)
|
||||
|
||||
expect(onEventMock.mock.calls[2][0]).toEqual("request")
|
||||
|
||||
const connectEvent: (socket) => any = onEventMock.mock.calls[0][1]
|
||||
|
||||
const onSocketClose = jest.fn()
|
||||
const socket = { on: onSocketClose }
|
||||
connectEvent(socket)
|
||||
expect(socket).toEqual(
|
||||
expect.objectContaining({
|
||||
_idle: true,
|
||||
_connectionId: 1,
|
||||
})
|
||||
)
|
||||
|
||||
const socket2 = { on: onSocketClose }
|
||||
connectEvent(socket2)
|
||||
expect(socket2).toEqual(
|
||||
expect.objectContaining({
|
||||
_idle: true,
|
||||
_connectionId: 2,
|
||||
})
|
||||
)
|
||||
|
||||
const requestMock = onEventMock.mock.calls[2][1]
|
||||
expect(typeof requestMock).toEqual("function")
|
||||
|
||||
const socket3 = { on: onSocketClose }
|
||||
const req = { socket: socket3, on: jest.fn() }
|
||||
const res = { on: jest.fn() }
|
||||
connectEvent(socket3)
|
||||
requestMock(req, res)
|
||||
|
||||
const finishRequestMock = res.on.mock.calls[0][1]
|
||||
|
||||
expect(socket3).toEqual(
|
||||
expect.objectContaining({
|
||||
_idle: false,
|
||||
_connectionId: 3,
|
||||
})
|
||||
)
|
||||
finishRequestMock()
|
||||
expect(socket3).toEqual(
|
||||
expect.objectContaining({
|
||||
_idle: true,
|
||||
_connectionId: 3,
|
||||
})
|
||||
)
|
||||
|
||||
expect(onSocketClose).toBeCalledTimes(3)
|
||||
expect(onSocketClose.mock.calls[0][0]).toEqual("close")
|
||||
})
|
||||
|
||||
it("waits requests to complete before shutting the server down", (done: Function) => {
|
||||
jest.useFakeTimers()
|
||||
|
||||
const onEventMock = jest.fn()
|
||||
const setIntervalSpy = jest.spyOn(global, "setInterval")
|
||||
const setTimeoutSpy = jest.spyOn(global, "setTimeout")
|
||||
const clearIntervalSpy = jest.spyOn(global, "clearInterval")
|
||||
|
||||
const waitTime = 200
|
||||
let closeServerCallback: Function
|
||||
const server = GracefulShutdownServer.create(
|
||||
{
|
||||
close: (callback) => {
|
||||
closeServerCallback = callback
|
||||
},
|
||||
on: onEventMock,
|
||||
} as any,
|
||||
waitTime
|
||||
)
|
||||
|
||||
const requestMock = onEventMock.mock.calls[2][1]
|
||||
const connectEvent: (socket) => any = onEventMock.mock.calls[0][1]
|
||||
|
||||
expect(typeof requestMock).toEqual("function")
|
||||
|
||||
const socket = { on: jest.fn(), destroy: jest.fn() }
|
||||
const req = { socket, on: jest.fn() }
|
||||
const res = { on: jest.fn() }
|
||||
connectEvent(socket)
|
||||
|
||||
requestMock(req, res)
|
||||
|
||||
const finishRequestMock = res.on.mock.calls[0][1]
|
||||
|
||||
server.shutdown().then(() => {
|
||||
done()
|
||||
})
|
||||
|
||||
expect(setTimeoutSpy).toBeCalledTimes(0)
|
||||
expect(setIntervalSpy).toBeCalledTimes(1)
|
||||
expect(setIntervalSpy.mock.calls[0][1]).toEqual(waitTime)
|
||||
expect(clearIntervalSpy).toBeCalledTimes(0)
|
||||
expect(socket.destroy).toBeCalledTimes(0)
|
||||
|
||||
jest.advanceTimersByTime(200)
|
||||
|
||||
expect(socket.destroy).toBeCalledTimes(0)
|
||||
|
||||
finishRequestMock()
|
||||
|
||||
expect(socket.destroy).toBeCalledTimes(0)
|
||||
|
||||
jest.advanceTimersByTime(waitTime)
|
||||
|
||||
expect(socket.destroy).toBeCalledTimes(1)
|
||||
|
||||
closeServerCallback!()
|
||||
})
|
||||
|
||||
it("should force close all connections after the timeout is reached", (done: Function) => {
|
||||
jest.useFakeTimers()
|
||||
|
||||
const onEventMock = jest.fn()
|
||||
const setIntervalSpy = jest.spyOn(global, "setInterval")
|
||||
const setTimeoutSpy = jest.spyOn(global, "setTimeout")
|
||||
const clearIntervalSpy = jest.spyOn(global, "clearInterval")
|
||||
|
||||
const waitTime = 300
|
||||
let closeServerCallback: Function
|
||||
const server = GracefulShutdownServer.create(
|
||||
{
|
||||
close: (callback) => {
|
||||
closeServerCallback = callback
|
||||
},
|
||||
on: onEventMock,
|
||||
} as any,
|
||||
waitTime
|
||||
)
|
||||
|
||||
const requestMock = onEventMock.mock.calls[2][1]
|
||||
const connectEvent: (socket) => any = onEventMock.mock.calls[0][1]
|
||||
|
||||
expect(typeof requestMock).toEqual("function")
|
||||
|
||||
const socket = { on: jest.fn(), destroy: jest.fn() }
|
||||
const req = { socket, on: jest.fn() }
|
||||
const res = { on: jest.fn() }
|
||||
connectEvent(socket)
|
||||
|
||||
requestMock(req, res) // pending request
|
||||
|
||||
const forceTimeout = 600
|
||||
server.shutdown(forceTimeout).then(() => {
|
||||
done()
|
||||
})
|
||||
|
||||
expect(setTimeoutSpy).toBeCalledTimes(1)
|
||||
expect(setTimeoutSpy.mock.calls[0][1]).toEqual(forceTimeout)
|
||||
expect(setIntervalSpy).toBeCalledTimes(1)
|
||||
expect(setIntervalSpy.mock.calls[0][1]).toEqual(waitTime)
|
||||
expect(clearIntervalSpy).toBeCalledTimes(0)
|
||||
expect(socket.destroy).toBeCalledTimes(0)
|
||||
|
||||
jest.advanceTimersByTime(waitTime)
|
||||
expect(socket.destroy).toBeCalledTimes(0)
|
||||
|
||||
jest.advanceTimersByTime(forceTimeout)
|
||||
expect(socket.destroy).toBeCalledTimes(1)
|
||||
|
||||
closeServerCallback!()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { join } from "path"
|
||||
import { FileSystem } from "../file-system"
|
||||
import { loadEnv } from "../load-env"
|
||||
|
||||
const filesystem = new FileSystem(join(__dirname, "tmp"))
|
||||
|
||||
describe("loadEnv", function () {
|
||||
afterEach(async () => {
|
||||
await filesystem.cleanup()
|
||||
delete process.env.MEDUSA_VERSION
|
||||
delete process.env.MEDUSA_DEV_VERSION
|
||||
delete process.env.MEDUSA_TEST_VERSION
|
||||
delete process.env.MEDUSA_STAGING_VERSION
|
||||
delete process.env.MEDUSA_PRODUCTION_VERSION
|
||||
})
|
||||
|
||||
it("should load .env file when in unknown environment", async function () {
|
||||
await filesystem.create(".env", "MEDUSA_VERSION=1.0")
|
||||
loadEnv("", filesystem.basePath)
|
||||
|
||||
expect(process.env.MEDUSA_VERSION).toEqual("1.0")
|
||||
})
|
||||
|
||||
it("should load .env file for known environments", async function () {
|
||||
await filesystem.create(".env", "MEDUSA_DEV_VERSION=1.0")
|
||||
await filesystem.create(".env.test", "MEDUSA_TEST_VERSION=1.0")
|
||||
await filesystem.create(".env.staging", "MEDUSA_STAGING_VERSION=1.0")
|
||||
await filesystem.create(".env.production", "MEDUSA_PRODUCTION_VERSION=1.0")
|
||||
|
||||
loadEnv("development", filesystem.basePath)
|
||||
loadEnv("test", filesystem.basePath)
|
||||
loadEnv("staging", filesystem.basePath)
|
||||
loadEnv("production", filesystem.basePath)
|
||||
|
||||
expect(process.env.MEDUSA_DEV_VERSION).toEqual("1.0")
|
||||
expect(process.env.MEDUSA_TEST_VERSION).toEqual("1.0")
|
||||
expect(process.env.MEDUSA_STAGING_VERSION).toEqual("1.0")
|
||||
expect(process.env.MEDUSA_PRODUCTION_VERSION).toEqual("1.0")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { dirname, join } from "path"
|
||||
import {
|
||||
promises,
|
||||
constants,
|
||||
type Dirent,
|
||||
type RmOptions,
|
||||
type StatOptions,
|
||||
type WriteFileOptions,
|
||||
type MakeDirectoryOptions,
|
||||
} from "fs"
|
||||
|
||||
const { rm, stat, mkdir, access, readdir, readFile, writeFile } = promises
|
||||
|
||||
export type JSONFileOptions = WriteFileOptions & {
|
||||
spaces?: number | string
|
||||
replacer?: (this: any, key: string, value: any) => any
|
||||
}
|
||||
|
||||
/**
|
||||
* File system abstraction to create and cleanup files during
|
||||
* tests
|
||||
*/
|
||||
export class FileSystem {
|
||||
constructor(public basePath: string) {}
|
||||
|
||||
private makePath(filePath: string) {
|
||||
return join(this.basePath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup directory
|
||||
*/
|
||||
async cleanup(options?: RmOptions) {
|
||||
return rm(this.basePath, {
|
||||
recursive: true,
|
||||
maxRetries: 10,
|
||||
force: true,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a directory inside the root of the filesystem
|
||||
* path. You may use this method to create nested
|
||||
* directories as well.
|
||||
*/
|
||||
mkdir(dirPath: string, options?: MakeDirectoryOptions) {
|
||||
return mkdir(this.makePath(dirPath), { recursive: true, ...options })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new file
|
||||
*/
|
||||
async create(filePath: string, contents: string, options?: WriteFileOptions) {
|
||||
const absolutePath = this.makePath(filePath)
|
||||
await mkdir(dirname(absolutePath), { recursive: true })
|
||||
return writeFile(this.makePath(filePath), contents, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file
|
||||
*/
|
||||
async remove(filePath: string, options?: RmOptions) {
|
||||
return rm(this.makePath(filePath), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 2,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the root of the filesystem exists
|
||||
*/
|
||||
async rootExists() {
|
||||
try {
|
||||
await access(this.basePath, constants.F_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async exists(filePath: string) {
|
||||
try {
|
||||
await access(this.makePath(filePath), constants.F_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns file contents
|
||||
*/
|
||||
async contents(filePath: string) {
|
||||
return readFile(this.makePath(filePath), "utf-8")
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps file contents to the stdout
|
||||
*/
|
||||
async dump(filePath: string) {
|
||||
console.log("------------------------------------------------------------")
|
||||
console.log(`file path => "${filePath}"`)
|
||||
console.log(`contents => "${await this.contents(filePath)}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stats for a file
|
||||
*/
|
||||
async stats(filePath: string, options?: StatOptions) {
|
||||
return stat(this.makePath(filePath), options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively reads files from a given directory
|
||||
*/
|
||||
readDir(dirPath?: string): Promise<Dirent[]> {
|
||||
const location = dirPath ? this.makePath(dirPath) : this.basePath
|
||||
return readdir(location, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a json file
|
||||
*/
|
||||
async createJson(filePath: string, contents: any, options?: JSONFileOptions) {
|
||||
if (options && typeof options === "object") {
|
||||
const { replacer, spaces, ...rest } = options
|
||||
return this.create(
|
||||
filePath,
|
||||
JSON.stringify(contents, replacer, spaces),
|
||||
rest
|
||||
)
|
||||
}
|
||||
|
||||
return this.create(filePath, JSON.stringify(contents), options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a json file
|
||||
*/
|
||||
async contentsJson(filePath: string) {
|
||||
const contents = await readFile(this.makePath(filePath), "utf-8")
|
||||
return JSON.parse(contents)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Server } from "http"
|
||||
import { Socket } from "net"
|
||||
import Timeout = NodeJS.Timeout
|
||||
|
||||
interface SocketState extends Socket {
|
||||
_idle: boolean
|
||||
_connectionId: number
|
||||
}
|
||||
|
||||
export abstract class GracefulShutdownServer {
|
||||
public isShuttingDown: boolean
|
||||
public abstract shutdown(timeout?: number): Promise<void>
|
||||
public static create<T extends Server>(
|
||||
originalServer: T,
|
||||
waitingResponseTime: number = 200
|
||||
): T & GracefulShutdownServer {
|
||||
let connectionId = 0
|
||||
let shutdownPromise: Promise<void>
|
||||
|
||||
const allSockets: { [id: number]: SocketState } = {}
|
||||
|
||||
const server = originalServer as T & GracefulShutdownServer
|
||||
server.isShuttingDown = false
|
||||
server.shutdown = async (timeout: number = 0): Promise<void> => {
|
||||
if (server.isShuttingDown) {
|
||||
return shutdownPromise
|
||||
}
|
||||
|
||||
server.isShuttingDown = true
|
||||
|
||||
shutdownPromise = new Promise((ok, nok) => {
|
||||
let forceQuit = false
|
||||
let cleanInterval: Timeout
|
||||
|
||||
try {
|
||||
// stop accepting new incoming connections
|
||||
server.close(() => {
|
||||
clearInterval(cleanInterval)
|
||||
ok()
|
||||
})
|
||||
|
||||
if (+timeout > 0) {
|
||||
setTimeout(() => {
|
||||
forceQuit = true
|
||||
}, timeout).unref()
|
||||
}
|
||||
|
||||
cleanInterval = setInterval(() => {
|
||||
if (!Object.keys(allSockets).length) {
|
||||
clearInterval(cleanInterval)
|
||||
}
|
||||
|
||||
for (const key of Object.keys(allSockets)) {
|
||||
const socketId = +key
|
||||
if (forceQuit || allSockets[socketId]._idle) {
|
||||
allSockets[socketId].destroy()
|
||||
delete allSockets[socketId]
|
||||
}
|
||||
}
|
||||
}, waitingResponseTime)
|
||||
} catch (error) {
|
||||
clearInterval(cleanInterval!)
|
||||
return nok(error)
|
||||
}
|
||||
})
|
||||
|
||||
return shutdownPromise
|
||||
}
|
||||
|
||||
const onConnect = (originalSocket) => {
|
||||
connectionId++
|
||||
const socket = originalSocket as SocketState
|
||||
socket._idle = true
|
||||
socket._connectionId = connectionId
|
||||
allSockets[connectionId] = socket
|
||||
|
||||
socket.on("close", () => {
|
||||
delete allSockets[socket._connectionId]
|
||||
})
|
||||
}
|
||||
|
||||
server.on("connection", onConnect)
|
||||
server.on("secureConnection", onConnect)
|
||||
|
||||
server.on("request", (req, res) => {
|
||||
const customSocket = req.socket as SocketState
|
||||
customSocket._idle = false
|
||||
|
||||
res.on("finish", () => {
|
||||
customSocket._idle = true
|
||||
})
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
}
|
||||
@@ -61,3 +61,6 @@ export * from "./to-handle"
|
||||
export * from "./validate-handle"
|
||||
export * from "./parse-cors-origins"
|
||||
export * from "./build-regexp-if-valid"
|
||||
export * from "./load-env"
|
||||
export * from "./file-system"
|
||||
export * from "./graceful-shutdown-server"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import dotenv from "dotenv"
|
||||
import { join } from "path"
|
||||
const KNOWN_ENVIRONMENTS = ["staging", "production", "test"]
|
||||
|
||||
/**
|
||||
* Loads ".env" file based upon the environment in which the
|
||||
* app is running.
|
||||
*
|
||||
* - Loads ".env" file by default.
|
||||
* - Loads ".env.staging" when "environment=staging".
|
||||
* - Loads ".env.production" when "environment=production".
|
||||
* - Loads ".env.test" when "environment=test".
|
||||
*
|
||||
* This method does not return any value and updates the "process.env"
|
||||
* object instead.
|
||||
*/
|
||||
export function loadEnv(environment: string, envDir: string) {
|
||||
const fileToLoad = KNOWN_ENVIRONMENTS.includes(environment)
|
||||
? `.env.${environment}`
|
||||
: ".env"
|
||||
try {
|
||||
dotenv.config({ path: join(envDir, fileToLoad) })
|
||||
} catch {}
|
||||
}
|
||||
Reference in New Issue
Block a user