diff --git a/bun/.gitignore b/bun/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/bun/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/bun/Dockerfile b/bun/Dockerfile new file mode 100644 index 00000000..e289a6f4 --- /dev/null +++ b/bun/Dockerfile @@ -0,0 +1,18 @@ +ARG BUN_BASE_IMG + +FROM oven/bun:${BUN_BASE_IMG} + +ARG BUN_ENV +ENV BUN_ENV $BUN_ENV + +RUN mkdir -p /usr/src/app +WORKDIR /usr/src/app + +COPY package.json /usr/src/app/ +RUN bun install +COPY server.ts /usr/src/app/server.ts +COPY tsconfig.json /usr/src/app/tsconfig.json + +CMD [ "bun", "run", "server.ts" ] + +EXPOSE 8888 \ No newline at end of file diff --git a/bun/Makefile b/bun/Makefile new file mode 100644 index 00000000..d705915e --- /dev/null +++ b/bun/Makefile @@ -0,0 +1,16 @@ +-include ../rules.mk + +.PHONY: all +all: bun-builder bun-env-img bun-env-debian-img bun-env-1140-img + +bun-env-img-buildargs := --build-arg BUN_BASE_IMG=1.1.40-alpine + +bun-env-debian-img-buildargs := --build-arg BUN_BASE_IMG=1.1.40 + +bun-env-1140-img-buildargs := --build-arg BUN_BASE_IMG=1.1.40-alpine + +bun-env-img: Dockerfile + +bun-env-debian-img: Dockerfile + +bun-env-1140-img: Dockerfile \ No newline at end of file diff --git a/bun/README.md b/bun/README.md new file mode 100644 index 00000000..953dd183 --- /dev/null +++ b/bun/README.md @@ -0,0 +1,53 @@ +# Fission: Bun Environment + +This is the Bun environment for Fission. + +It's a Docker image containing a Bun runtime, along with a dynamic +loader. A few common dependencies are included in the package.json +file. + +Looking for ready-to-run examples? See the [Bun examples directory](../../examples/bun). + +## Customizing this image + +To add package dependencies, edit [package.json](./package.json) to add what you need, and rebuild this image (instructions below). + +You also may want to customize what's available to the function in it's request context. +You can do this by editing [server.js](./server.js) (see the comment in that file about customizing request context). + +## Rebuilding and pushing the image + +You'll need access to a Docker registry to push the image: you can sign up for Docker hub at hub.docker.com, or use registries from gcr.io, quay.io, etc. +Let's assume you're using a docker hub account called USER. +Build and push the image to the the registry: + +Building runtime image, + +```console +docker build -t USER/bun-env --build-arg BUN_BASE_IMG=1.1.40-alpine -f Dockerfile . +docker push USER/bun-env +``` + +Building builder image, + +```console +cd builder && docker build -t USER/bun-builder --build-arg BUN_BASE_IMG=1.1.40-alpine -f Dockerfile . +docker push USER/bun-builder +``` + +## Using the image in fission + +You can add this customized image to fission with "fission env create": + +```console +fission env create --name bun --image USER/bun-env +``` + +Or, if you already have an environment, you can update its image: + +```console +fission env update --name bun --image USER/bun-env +``` + +After this, fission functions that have the env parameter set to the +same environment name as this command will use this environment. diff --git a/bun/builder/Dockerfile b/bun/builder/Dockerfile new file mode 100644 index 00000000..37c22e78 --- /dev/null +++ b/bun/builder/Dockerfile @@ -0,0 +1,13 @@ +ARG BUN_BASE_IMG +ARG BUILDER_IMAGE=fission/builder:latest + +FROM ${BUILDER_IMAGE} + +FROM oven/bun:${BUN_BASE_IMG} + +ARG BUN_ENV +ENV BUN_ENV $BUN_ENV + +COPY --from=0 /builder /builder +ADD build.sh /usr/local/bin/build +RUN chmod +x /usr/local/bin/build diff --git a/bun/builder/Makefile b/bun/builder/Makefile new file mode 100644 index 00000000..f7db4c3a --- /dev/null +++ b/bun/builder/Makefile @@ -0,0 +1,16 @@ +-include ../../rules.mk + +.PHONY: all +all: bun-builder-debian-img bun-builder-img bun-builder-1140-img + +bun-builder-debian-img-buildargs := --build-arg BUN_BASE_IMG=1.1.40 + +bun-builder-img-buildargs := --build-arg BUN_BASE_IMG=1.1.40-alpine + +bun-builder-1140-img-buildargs := --build-arg BUN_BASE_IMG=1.1.40-alpine + +bun-builder-debian-img: Dockerfile + +bun-builder-img: Dockerfile + +bun-builder-1140-img: Dockerfile diff --git a/bun/builder/build.sh b/bun/builder/build.sh new file mode 100755 index 00000000..ff79599f --- /dev/null +++ b/bun/builder/build.sh @@ -0,0 +1,10 @@ +#!/bin/sh +cd ${SRC_PKG} + +if [[ -n "$NPM_TOKEN" ]] && [[ -n "$NPM_REGISTRY" ]]; then + touch bunfig.toml + echo "[install.scopes]" >> bunfig.toml + echo "\"@private\" = { token = \"$NPM_TOKEN\", url = \"$NPM_REGISTRY\" }" >> bunfig.toml +fi + +bun install && cp -r ${SRC_PKG} ${DEPLOY_PKG} diff --git a/bun/bun.lockb b/bun/bun.lockb new file mode 100755 index 00000000..6ee12744 Binary files /dev/null and b/bun/bun.lockb differ diff --git a/bun/envconfig.json b/bun/envconfig.json new file mode 100644 index 00000000..92862694 --- /dev/null +++ b/bun/envconfig.json @@ -0,0 +1,61 @@ +[ + { + "examples": "https://github.com/fission/environments/tree/master/bunjs/examples", + "icon": "./logo/bunjs-new-pantone-black.svg", + "image": "bun-env-debian", + "keywords": [], + "kind": "environment", + "maintainers": [ + { + "link": "https://github.com/danhtran94", + "name": "danhtran94" + } + ], + "name": "Bunjs Environment", + "readme": "https://github.com/fission/environments/tree/master/bunjs", + "runtimeVersion": "20.16.0-debian", + "shortDescription": "Fission Bunjs environment based on Express with some basic dependencies added", + "status": "Stable", + "version": "1.32.4" + }, + { + "builder": "bun-builder-1140", + "examples": "https://github.com/fission/environments/tree/master/bunjs/examples", + "icon": "./logo/bunjs-new-pantone-black.svg", + "image": "bun-env-1140", + "keywords": [], + "kind": "environment", + "maintainers": [ + { + "link": "https://github.com/danhtran94", + "name": "danhtran94" + } + ], + "name": "Bunjs Environment", + "readme": "https://github.com/fission/environments/tree/master/bunjs", + "runtimeVersion": "22.6.0", + "shortDescription": "Fission Bunjs environment based on Express with some basic dependencies added", + "status": "Stable", + "version": "1.32.4" + }, + { + "builder": "bun-builder", + "examples": "https://github.com/fission/environments/tree/master/bunjs/examples", + "icon": "./logo/bunjs-new-pantone-black.svg", + "image": "bun-env", + "keywords": [], + "kind": "environment", + "maintainers": [ + { + "link": "https://github.com/danhtran94", + "name": "danhtran94" + } + ], + "name": "Bunjs Environment", + "readme": "https://github.com/fission/environments/tree/master/bunjs", + "runtimeVersion": "20.16.0", + "shortDescription": "Fission Bunjs environment based on Express with some basic dependencies added", + "status": "Stable", + "version": "1.32.4" + } +] diff --git a/bun/package.json b/bun/package.json new file mode 100644 index 00000000..a9cd6649 --- /dev/null +++ b/bun/package.json @@ -0,0 +1,32 @@ +{ + "name": "fission-bun-runtime", + "version": "0.1.0", + "author": "danhtran94", + "contributors": [ + { + "name": "Tran Thanh Danh", + "email": "danh.tt1294@gmail.com" + } + ], + "description": "BunJS run container for the fission framework", + "engines": { + }, + "dependencies": { + "co": "~4.6.0", + "express": ">=4.0.0", + "minimist": "*", + "morgan": "*", + "mz": "~2.7.0", + "request": "^2.81.0", + "request-promise-native": "^1.0.3", + "underscore": ">=1.8.3", + "ws": "^7.5.5" + }, + "devDependencies": { + "@types/bun": "^1.1.14", + "@types/express": "^5.0.0", + "@types/minimist": "^1.2.5", + "@types/morgan": "^1.9.9", + "@types/request": "^2.48.12" + } +} diff --git a/bun/server.ts b/bun/server.ts new file mode 100644 index 00000000..b52e0b82 --- /dev/null +++ b/bun/server.ts @@ -0,0 +1,249 @@ +import fs from "fs"; +import path from "path"; +import process from "process"; +import type { Request, Response } from "express"; +import express from "express"; +import request from "request"; +import morgan from "morgan"; +import { WebSocketServer, WebSocket } from "ws"; +import minimist from "minimist"; +import http from "http"; + +const app = express(); +const argv = minimist(process.argv.slice(1)); // Command line opts + +if (!argv["port"]) { + argv["port"] = 8888; +} + +// Interval at which we poll for connections to be active +let timeout: number; +if (process.env["TIMEOUT"]) { + timeout = parseInt(process.env["TIMEOUT"], 10); +} else { + timeout = 60000; +} + +// To catch unhandled exceptions thrown by user code async callbacks, +// these exceptions cannot be caught by try-catch in user function invocation code below +process.on("uncaughtException", (err) => { + console.error(`Caught exception: ${err}`); +}); + +// User function. Starts out undefined. +let userFunction: Function | undefined; + +const loadFunction = async (modulepath: string, funcname?: string) => { + try { + let startTime = process.hrtime(); + const pkg = await import(modulepath); + let userFunction = funcname ? pkg[funcname] : pkg.default; + + let elapsed = process.hrtime(startTime); + console.log( + `user code loaded in ${elapsed[0]}sec ${elapsed[1] / 1000000}ms` + ); + + return userFunction; + } catch (e: any) { + console.error(`user code load error: ${e}`); + return e; + } +}; + +const withEnsureGeneric = ( + func: (req: Request, res: Response) => Promise +) => { + return (req: Request, res: Response) => { + if (userFunction) { + res.status(400).send("Not a generic container"); + return; + } + func(req, res); + }; +}; + +const isFunction = (func: any): func is Function => { + return func && func.constructor && func.call && func.apply; +}; + +const specializeV2 = async (req: Request, res: Response) => { + const entrypoint = req.body.functionName + ? req.body.functionName.split(".") + : []; + const modulepath = path.join(req.body.filepath, entrypoint[0] || ""); + const result = await loadFunction(modulepath, entrypoint[1]); + + if (isFunction(result)) { + userFunction = result; + res.status(202).send(); + } else { + res.status(500).send(JSON.stringify(result)); + } +}; + +const specialize = async (req: Request, res: Response) => { + const modulepath = argv["codepath"] || "/userfunc/user"; + + if (!fs.existsSync(`${path.dirname(modulepath)}/node_modules`)) { + fs.symlinkSync( + "/usr/src/app/node_modules", + `${path.dirname(modulepath)}/node_modules` + ); + } + const result = await loadFunction(modulepath); + + if (isFunction(result)) { + userFunction = result; + res.status(202).send(); + } else { + res.status(500).send(JSON.stringify(result)); + } +}; + +// Request logger +app.use(morgan("combined")); + +let bodyParserLimit = process.env["BODY_PARSER_LIMIT"] || "1mb"; + +app.use(express.urlencoded({ extended: false, limit: bodyParserLimit })); +app.use(express.json({ limit: bodyParserLimit })); +app.use(express.raw({ limit: bodyParserLimit })); +app.use(express.text({ type: "text/*", limit: bodyParserLimit })); + +app.post("/specialize", withEnsureGeneric(specialize)); +app.post("/v2/specialize", withEnsureGeneric(specializeV2)); + +// Generic route -- all http requests go to the user function. +app.all("*", (req: Request, res: Response) => { + if (!userFunction) { + res.status(500).send("Generic container: no requests supported"); + return; + } + + const context = { + request: req, + response: res, + }; + + const callback = ( + status: number, + body: any, + headers?: { [key: string]: string } + ) => { + if (!status) return; + if (headers) { + for (let name of Object.keys(headers)) { + res.set(name, headers[name]); + } + } + res.status(status).send(body); + }; + + if (userFunction.length <= 1) { + let result: Promise; + if (userFunction.length === 0) { + result = Promise.resolve(userFunction()); + } else { + result = Promise.resolve(userFunction(context)); + } + result + .then(({ status, body, headers }) => { + callback(status, body, headers); + }) + .catch((err) => { + console.log(`Function error: ${err}`); + callback(500, "Internal server error"); + }); + } else { + try { + userFunction(context, callback); + } catch (err) { + console.log(`Function error: ${err}`); + callback(500, "Internal server error"); + } + } +}); + +let server = http.createServer(); + +// Also mount the app here +server.on("request", app); + +const wsStartEvent = { + url: "http://127.0.0.1:8000/wsevent/start", +}; + +const wsInactiveEvent = { + url: "http://127.0.0.1:8000/wsevent/end", +}; + +// Create web socket server on top of a regular http server +let wss = new WebSocketServer({ + server: server, +}); + +const noop = () => {}; + +const heartbeat = function (this: WebSocket) { + (this as any).isAlive = true; +}; + +let warm = false; + +let interval = setInterval(() => { + if (warm) { + if (wss.clients.size > 0) { + wss.clients.forEach((ws) => { + if ((ws as any).isAlive === false) return ws.terminate(); + + (ws as any).isAlive = false; + ws.ping(noop); + }); + } else { + request(wsInactiveEvent, (err, res) => { + if (err || res.statusCode != 200) { + if (err) { + console.log(err); + } else { + console.log("Unexpected response"); + } + return; + } + }); + return; + } + } +}, timeout); + +wss.on("connection", (ws) => { + if (warm == false) { + warm = true; + request(wsStartEvent, (err, res) => { + if (err || res.statusCode != 200) { + if (err) { + console.log(err); + } else { + console.log("Unexpected response"); + } + return; + } + }); + } + + (ws as any).isAlive = true; + ws.on("pong", heartbeat); + + wss.on("close", () => { + clearInterval(interval); + }); + + try { + userFunction?.(ws, wss.clients); + } catch (err) { + console.log(`Function error: ${err}`); + ws.close(); + } +}); + +server.listen(argv["port"], () => {}); diff --git a/bun/tsconfig.json b/bun/tsconfig.json new file mode 100644 index 00000000..59a229d4 --- /dev/null +++ b/bun/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": true + } +} \ No newline at end of file