Skip to content

Commit

Permalink
feat(runtime&server): support updating dependencies without restarting (
Browse files Browse the repository at this point in the history
#1823)

* feat(runtime): support updating dependencies without restarting

* feat(server): publish conf when dep changes

* change dev script

* clear module cache

* fix upload node_modules
  • Loading branch information
0fatal authored Feb 5, 2024
1 parent bd69fea commit debd01c
Show file tree
Hide file tree
Showing 11 changed files with 280 additions and 51 deletions.
2 changes: 2 additions & 0 deletions runtimes/nodejs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ ENV FORCE_COLOR=1
COPY . /app
# COPY --chown=node:node . /app
RUN mkdir /app/data || true
RUN mkdir /tmp/custom_dependency || true
RUN chown node:node /app/data
RUN chown node:node /app/functions
RUN chown node:node /tmp/custom_dependency
# RUN npm install
# RUN npm run build
RUN chown -R node:node /app/node_modules
Expand Down
5 changes: 5 additions & 0 deletions runtimes/nodejs/Dockerfile.init
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,10 @@ FROM node:20.10.0
WORKDIR /app

COPY ./init.sh /app/init.sh
COPY ./upload-dependencies.sh /app/upload-dependencies.sh

RUN chown -R node:node /app

USER node

CMD [ "sh", "/app/init.sh" ]
33 changes: 1 addition & 32 deletions runtimes/nodejs/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,35 +83,4 @@ end_time=$(date +%s)
elapsed_time=$(expr $end_time - $start_time)
echo "Installed dependencies in $elapsed_time seconds."

### cache node_modules ###
# if $NODE_MODULES_PUSH_URL is not empty
if [ -n "$NODE_MODULES_PUSH_URL" ]; then
# temporarily disable set -e
set +e

start_time=$(date +%s)
echo $DEPENDENCIES > node_modules/.dependencies
echo "Uploading node_modules to $NODE_MODULES_PUSH_URL"

# tar `node_modules` to node_modules.tar
tar -cf node_modules.tar ./node_modules

end_time_1=$(date +%s)
elapsed_time=$(expr $end_time_1 - $start_time)
echo "Compressed node_modules in $elapsed_time seconds."

# upload node_modules.tar to $NODE_MODULES_PUSH_URL
curl -sSfL -X PUT -T node_modules.tar $NODE_MODULES_PUSH_URL


if [ $? -ne 0 ]; then
echo "Failed to upload node_modules cache."
else
end_time_2=$(date +%s)
elapsed_time_2=$(expr $end_time_2 - $end_time)
echo "Uploaded node_modules.tar in $elapsed_time_2 seconds."
fi

# re-enable set -e
set -e
fi
sh /app/upload-dependencies.sh /app
2 changes: 1 addition & 1 deletion runtimes/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"private": "true",
"scripts": {
"start": "node ./dist/index.js",
"dev": "npx concurrently npm:dev:*",
"dev": "npx ts-node ./src/index.ts",
"dev:start": "npx nodemon ./dist/index.js",
"dev:watch": "npm run watch",
"build": "tsc -p tsconfig.json",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { CONFIG_COLLECTION } from '../../constants'
import { DatabaseAgent } from '../../db'
import { DatabaseChangeStream } from '.'
import {
installDependencies,
uninstallDependencies,
} from '../module-hot-reload'

export class ConfChangeStream {
static dependencies = []

static initialize() {
this.updateEnvironments()
this.updateConfig(true)

DatabaseChangeStream.onStreamChange(
CONFIG_COLLECTION,
this.updateEnvironments,
DatabaseChangeStream.onStreamChange(CONFIG_COLLECTION, () =>
this.updateConfig(false),
)
}

private static async updateEnvironments() {
private static async updateConfig(init = false) {
const conf = await DatabaseAgent.db
.collection(CONFIG_COLLECTION)
.findOne({})
Expand All @@ -25,5 +30,34 @@ export class ConfChangeStream {
for (const env of environments) {
process.env[env.name] = env.value
}

if (init) {
ConfChangeStream.dependencies = conf.dependencies
return
}

const newDeps = []
const unneededDeps = []

for (const dep of conf.dependencies) {
if (!ConfChangeStream.dependencies.includes(dep)) {
newDeps.push(dep)
}
}

for (const dep of ConfChangeStream.dependencies) {
if (!conf.dependencies.includes(dep)) {
unneededDeps.push(dep)
}
}

ConfChangeStream.dependencies = conf.dependencies

if (newDeps.length > 0) {
installDependencies(newDeps)
}
if (unneededDeps.length > 0) {
uninstallDependencies(unneededDeps)
}
}
}
6 changes: 2 additions & 4 deletions runtimes/nodejs/src/support/engine/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import { createRequire } from 'node:module'
import * as path from 'node:path'
import { ObjectId } from 'mongodb'

const CUSTOM_DEPENDENCY_NODE_MODULES_PATH = `${Config.CUSTOM_DEPENDENCY_BASE_PATH}/node_modules/`
export const CUSTOM_DEPENDENCY_NODE_MODULES_PATH = `${Config.CUSTOM_DEPENDENCY_BASE_PATH}/node_modules/`

export class FunctionModule {
protected static cache: Map<string, any> = new Map()

private static customRequire = createRequire(
CUSTOM_DEPENDENCY_NODE_MODULES_PATH,
)
static customRequire = createRequire(CUSTOM_DEPENDENCY_NODE_MODULES_PATH)

static get(functionName: string): any {
const moduleName = `@/${functionName}`
Expand Down
163 changes: 163 additions & 0 deletions runtimes/nodejs/src/support/module-hot-reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { exec } from 'child_process'
import * as fs from 'fs'
import Module from 'module'
import path from 'path'
import Config from '../config'
import { logger } from './logger'
import {
CUSTOM_DEPENDENCY_NODE_MODULES_PATH,
FunctionModule,
} from './engine/module'

// === override to disable cache
// @ts-ignore
const originModuleStat = Module._stat
// @ts-ignore
Module._stat = (filename: string) => {
if (!filename.startsWith(CUSTOM_DEPENDENCY_NODE_MODULES_PATH)) {
return originModuleStat(filename)
}
filename = path.toNamespacedPath(filename)

let stat
try {
stat = fs.statSync(filename)
} catch (e) {
return -2 // not found
}
if (stat.isDirectory()) {
return 1
}
return 0
}

// @ts-ignore
const originModuleReadPackage = Module._readPackage
// @ts-ignore
Module._readPackage = (requestPath: string) => {
const pkg = originModuleReadPackage(requestPath)
if (
pkg.exists === false &&
pkg.pjsonPath.startsWith(CUSTOM_DEPENDENCY_NODE_MODULES_PATH)
) {
try {
const _pkg = JSON.parse(fs.readFileSync(pkg.pjsonPath, 'utf8'))
pkg.main = _pkg.main
pkg.exists = true
} catch {}
}
return pkg
}
// ===

export function clearModuleCache(moduleId: string) {
let filePath: string

try {
filePath = FunctionModule.customRequire.resolve(moduleId)
} catch {}

if (!filePath) {
return
}

// Delete itself from module parent
if (require.cache[filePath] && require.cache[filePath].parent) {
let i = require.cache[filePath].parent.children.length

while (i--) {
if (require.cache[filePath].parent.children[i].id === filePath) {
require.cache[filePath].parent.children.splice(i, 1)
}
}
}

// Remove all descendants from cache as well
if (require.cache[filePath]) {
const children = require.cache[filePath].children.map((child) => child.id)

// Delete module from cache
delete require.cache[filePath]

for (const id of children) {
clearModuleCache(id)
}
}
}

const getPackageNameWithoutVersion = (name: string) =>
name.slice(0, name.indexOf('@', 1))

export function installDependency(packageName: string) {
return new Promise((resolve, reject) => {
logger.info(`Installing package ${packageName} ...`)
exec(
`cd ${
Config.CUSTOM_DEPENDENCY_BASE_PATH
} && npm install ${packageName} && (sh ${process.cwd()}/upload-dependencies.sh ${
Config.CUSTOM_DEPENDENCY_BASE_PATH
} > /dev/null 2>&1) &`,
(error, stdout) => {
if (error) {
logger.error(`Error installing package ${packageName}: ${error}`)
return reject(error)
}
// if (stderr) {
// logger.error(`Error installing package ${packageName}: ${stderr}`)
// return reject(new Error(stderr))
// }
logger.info(`Package ${packageName} installed success`)
resolve(stdout)
},
)
})
}

export function installDependencies(packageName: string[]) {
return installDependency(packageName.join(' '))
.catch(() => {})
.finally(() => {
packageName.forEach((v) => {
clearModuleCache(getPackageNameWithoutVersion(v))
})
})
}

export function uninstallDependency(packageName: string) {
return new Promise((resolve, reject) => {
logger.info(`Uninstalling package ${packageName} ...`)
exec(
`cd ${
Config.CUSTOM_DEPENDENCY_BASE_PATH
} && npm uninstall ${packageName} && (sh ${process.cwd()}/upload-dependencies.sh ${
Config.CUSTOM_DEPENDENCY_BASE_PATH
} > /dev/null 2>&1) &`,
(error, stdout) => {
if (error) {
logger.error(`Error uninstalling package ${packageName}: ${error}`)
return reject(error)
}
// if (stderr) {
// logger.error(`Error uninstalling package ${packageName}: ${stderr}`)
// return reject(new Error(stderr))
// }
logger.info(`Package ${packageName} uninstalled success`)
resolve(stdout)
},
)
})
}

export function uninstallDependencies(packageName: string[]) {
packageName.forEach((v) => clearModuleCache(getPackageNameWithoutVersion(v)))

return uninstallDependency(
packageName.map((v) => getPackageNameWithoutVersion(v)).join(' '),
)
.catch(() => {})
.finally(() => {
packageName.forEach((v) =>
clearModuleCache(getPackageNameWithoutVersion(v)),
)
})
}
7 changes: 7 additions & 0 deletions runtimes/nodejs/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

ln -s $CUSTOM_DEPENDENCY_BASE_PATH/node_modules $PWD/functions/node_modules > /dev/null 2>&1

# generate package.json
(
cd $CUSTOM_DEPENDENCY_BASE_PATH
echo '{}' > package.json
npm install $NPM_INSTALL_FLAGS > /dev/null 2>&1
)

# source .env
echo "****** start service: node $FLAGS --experimental-vm-modules --experimental-fetch ./dist/index.js *******"
exec node $FLAGS --experimental-vm-modules --experimental-fetch ./dist/index.js
33 changes: 33 additions & 0 deletions runtimes/nodejs/upload-dependencies.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
### cache node_modules ###
# if $NODE_MODULES_PUSH_URL is not empty
if [ -n "$NODE_MODULES_PUSH_URL" ]; then
NODE_MODULES_PATH=$1
# temporarily disable set -e
set +e

start_time=$(date +%s)
echo $DEPENDENCIES > $NODE_MODULES_PATH/node_modules/.dependencies
echo "Uploading node_modules to $NODE_MODULES_PUSH_URL"

# tar `node_modules` to node_modules.tar
tar -cf $NODE_MODULES_PATH/node_modules.tar $NODE_MODULES_PATH/node_modules

end_time_1=$(date +%s)
elapsed_time=$(expr $end_time_1 - $start_time)
echo "Compressed node_modules in $elapsed_time seconds."

# upload node_modules.tar to $NODE_MODULES_PUSH_URL
curl -sSfL -X PUT -T $NODE_MODULES_PATH/node_modules.tar $NODE_MODULES_PUSH_URL


if [ $? -ne 0 ]; then
echo "Failed to upload node_modules cache."
else
end_time_2=$(date +%s)
elapsed_time_2=$(expr $end_time_2 - $end_time)
echo "Uploaded node_modules.tar in $elapsed_time_2 seconds."
fi

# re-enable set -e
set -e
fi
Loading

0 comments on commit debd01c

Please sign in to comment.