diff --git a/function-spa/README.md b/function-spa/README.md new file mode 100644 index 00000000..6390441b --- /dev/null +++ b/function-spa/README.md @@ -0,0 +1,116 @@ +# Serve Static Web Apps as IBM Code Engine Functions + +This sample demonstrates how to package and serve static web applications (SPAs) as IBM Code Engine functions. It's designed for scenarios where a full Code Engine application is unnecessary: small static bundles, internal tools, previews, demos, or packaged customer-specific frontends. + +## Overview + +The function serves static files from a `dist/` directory with intelligent routing: +- Direct file serving for existing assets +- Fallback to `index.html` for client-side routing +- Proper cache headers (long-lived for assets, no-cache for HTML) +- Support for binary files (images, fonts, videos) + +## Prerequisites + +- IBM Cloud CLI with Code Engine plugin +- Node.js 18 or later +- A frontend project that builds to static files + +## Repository Structure + +```text +. +├── main.js # Function entrypoint +├── package.json # Function package definition +├── build-spa.sh # Build script (runs during deployment) +├── run-angular # Quick-start script for Angular +├── run-react # Quick-start script for React +└── run-svelte # Quick-start script for Svelte + +``` + +## Quick-Start use a Framework + +Create and deploy a new Angular, React, or Svelte project: + +```bash +# For Angular +./run-angular + +# For React +./run-react + +# For Svelte +./run-svelte +``` + +Each script creates a new project, builds it, and deploys it as a Code Engine function. + +## Local Development Workflow + +You can create any Single Page Application in this project and develop it locally using your normal workflow (e.g., hot reload, dev server, etc.). Once you're ready to deploy: + +1. **Develop locally** - Use your framework's development server and tools as usual +2. **Test your build** - Ensure `npm run build` produces static files in `dist/` or `build/` +3. **Deploy** - Run the function create or update command to deploy to Code Engine + +The build process automatically handles packaging your static files into the function during deployment. + +## How It Works + +### Build Process + +When you deploy to Code Engine, the build process: + +1. **Detects your frontend** - `build-spa.sh` finds the first non-hidden directory (excluding `node_modules` and `dist`) +2. **Installs dependencies** - Runs `npm ci` or `npm install` +3. **Builds the frontend** - Executes `npm run build` +4. **Locates output** - Finds `index.html` in either `dist/` or `build/` +5. **Copies to root** - Places build artifacts in root `dist/` directory +6. **Cleans up** - Removes the source frontend directory + +The function package includes only `main.js`, `package.json`, and `dist/`. + +### Request Handling + +The function in `main.js`: + +1. Extracts the request path from `__ce_path` +2. Sanitizes the path to prevent directory traversal +3. Attempts to serve the requested file directly from `dist/` +4. Falls back to `index.html` for paths without extensions (client-side routing) +5. Returns 404 for missing asset files + +### Cache Strategy + +- `index.html`: `Cache-Control: no-cache` (always check for updates) +- All other files: `Cache-Control: public, max-age=31536000` (1 year) + +This assumes your build process generates content-hashed filenames for assets. + +## Deployment + +### Create a New Function + +```bash +ibmcloud ce function create \ + --name my-static-app \ + --runtime nodejs-24 \ + --build-source . +``` + +### Update an Existing Function + +```bash +ibmcloud ce function update \ + --name my-static-app \ + --build-source . +``` + +### Get Function URL + +```bash +ibmcloud ce function get --name my-static-app +``` + +The function URL will be in the output under "URL". diff --git a/function-spa/build-spa.sh b/function-spa/build-spa.sh new file mode 100755 index 00000000..9052d545 --- /dev/null +++ b/function-spa/build-spa.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -e + +echo "[build-spa] Starting SPA build process" + +# Find frontend folder (exclude node_modules, dist, hidden dirs) +echo "[build-spa] Finding frontend folder..." +FRONTEND_DIR=$(find . -maxdepth 1 -type d ! -name "." ! -name ".." ! -name "node_modules" ! -name "dist" ! -name ".*" | head -n 1) + +if [ -z "$FRONTEND_DIR" ]; then + echo "[build-spa] ERROR: No frontend folder found" + exit 1 +fi + +FRONTEND_DIR=$(basename "$FRONTEND_DIR") +echo "[build-spa] Found frontend folder: $FRONTEND_DIR" + +# Install dependencies +echo "[build-spa] Installing dependencies in $FRONTEND_DIR..." +cd "$FRONTEND_DIR" +npm ci --production=false 2>/dev/null || npm install + +# Build +echo "[build-spa] Building SPA..." +npm run build + +# Find the output directory (dist or build) +echo "[build-spa] Locating build output directory..." +if [ -d "dist" ]; then + OUTPUT_DIR="dist" +elif [ -d "build" ]; then + OUTPUT_DIR="build" +else + echo "[build-spa] ERROR: No dist/ or build/ directory found after npm run build" + exit 1 +fi + +echo "[build-spa] Output directory found: $OUTPUT_DIR" + +# Find index.html within the output directory to locate the actual build artifacts +echo "[build-spa] Searching for index.html in $OUTPUT_DIR..." +INDEX_PATH=$(find "$OUTPUT_DIR" -name "index.html" -type f 2>/dev/null | head -n 1) + +if [ -z "$INDEX_PATH" ]; then + echo "[build-spa] ERROR: No index.html found in $OUTPUT_DIR" + exit 1 +fi + +# Get the directory containing index.html (this is where the build artifacts are) +BUILD_ARTIFACTS_DIR=$(dirname "$INDEX_PATH") +echo "[build-spa] Build artifacts found in: $BUILD_ARTIFACTS_DIR" + +# List files at the same level as index.html for verification +echo "[build-spa] Files at build artifacts level:" +ls -lh "$BUILD_ARTIFACTS_DIR" + +# Get absolute path of build artifacts directory +cd "$BUILD_ARTIFACTS_DIR" +ABSOLUTE_BUILD_DIR=$(pwd) +echo "[build-spa] Copying from: $ABSOLUTE_BUILD_DIR" + +# Go back to root (frontend directory) +cd - > /dev/null +cd .. + +# Create root dist directory and copy build artifacts +echo "[build-spa] Copying build artifacts to root dist/..." +mkdir -p dist +cp -r "$ABSOLUTE_BUILD_DIR/"* dist/ + +# Clean up +echo "[build-spa] Cleaning up source folder..." +rm -rf "$FRONTEND_DIR" + +echo "[build-spa] Build complete. Final structure: main.js, package.json, dist/" + diff --git a/function-spa/main.js b/function-spa/main.js new file mode 100644 index 00000000..7f64a410 --- /dev/null +++ b/function-spa/main.js @@ -0,0 +1,155 @@ +const fs = require('fs'); +const path = require('path'); + +const MIME_TYPES = { + '.css': 'text/css; charset=utf-8', + '.eot': 'application/vnd.ms-fontobject', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.mp4': 'video/mp4', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +const BINARY_EXTENSIONS = new Set([ + '.eot', + '.gif', + '.ico', + '.jpeg', + '.jpg', + '.mp4', + '.png', + '.webm', + '.webp', + '.woff', + '.woff2', + '.ttf', +]); + +function sanitizePath(value) { + return String(value || '') + .split('?')[0] + .split('#')[0] + .replace(/\.\./g, '') + .replace(/\/+/g, '/') + .replace(/^\/+/, ''); +} + +function getRequestPath(args) { + return sanitizePath(args.__ce_path || args.__ow_path || '/'); +} + +function getMethod(args) { + return args.__ce_method || args.__ow_method || 'GET'; +} + +function getContentType(filePath) { + return ( + MIME_TYPES[path.extname(filePath).toLowerCase()] || + 'application/octet-stream' + ); +} + +function isBinaryFile(filePath) { + return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +function readFile(filePath) { + try { + const fullPath = path.join(__dirname, 'dist', filePath); + + if (!fs.existsSync(fullPath)) { + return null; + } + + const buffer = fs.readFileSync(fullPath); + + return { + body: isBinaryFile(filePath) + ? buffer.toString('base64') + : buffer.toString('utf-8'), + contentType: getContentType(filePath), + }; + } catch { + return null; + } +} + +function createResponse(statusCode, contentType, body, cacheControl) { + return { + statusCode, + headers: { + 'Content-Type': contentType, + ...(cacheControl ? { 'Cache-Control': cacheControl } : {}), + }, + body, + }; +} + +function serveFile(filePath) { + const result = readFile(filePath); + + if (!result) { + return null; + } + + const cacheControl = + filePath === 'index.html' ? 'no-cache' : 'public, max-age=31536000'; + + return createResponse(200, result.contentType, result.body, cacheControl); +} + +function main(args) { + const requestPath = getRequestPath(args); + const method = getMethod(args); + + if (method !== 'GET') { + return createResponse( + 405, + 'text/plain; charset=utf-8', + 'Method Not Allowed', + ); + } + + const filePath = requestPath || 'index.html'; + const directResponse = serveFile(filePath); + + if (directResponse) { + return directResponse; + } + + if (path.extname(requestPath)) { + return createResponse( + 404, + 'text/plain; charset=utf-8', + `Not found: /${requestPath}`, + ); + } + + const indexResponse = serveFile('index.html'); + + if (indexResponse) { + return indexResponse; + } + + return createResponse( + 404, + 'text/plain; charset=utf-8', + `Not found: /${requestPath}`, + ); +} + +module.exports = main; +module.exports.main = main; diff --git a/function-spa/package.json b/function-spa/package.json new file mode 100644 index 00000000..5b4236bd --- /dev/null +++ b/function-spa/package.json @@ -0,0 +1,18 @@ +{ + "name": "static-content-function", + "version": "1.0.0", + "description": "Minimal static content function for IBM Code Engine", + "main": "main.js", + "scripts": { + "postinstall": "./build-spa.sh" + }, + "keywords": [ + "static-content", + "code-engine", + "ibm-cloud", + "function" + ], + "engines": { + "node": ">=22.0.0" + } +} diff --git a/function-spa/run-angular b/function-spa/run-angular new file mode 100755 index 00000000..d1c37f2e --- /dev/null +++ b/function-spa/run-angular @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +FUNCTION_NAME="my-spa-angular" +RUNTIME="nodejs-24" + +echo "[run-angular] Creating new Angular project: my-spa-angular" + +# Create Angular project with SSR disabled, using defaults and non-interactive mode +npx @angular/cli@latest new my-spa-angular \ + --directory=my-spa-angular \ + --routing=true \ + --style=css \ + --ssr=false \ + --skip-git=true \ + --package-manager=npm \ + --standalone=true \ + --defaults=true \ + --interactive=false + +echo "[run-angular] Angular project created successfully in my-spa-angular/" +echo "[run-angular] Building and deploying to IBM Cloud Code Engine..." + +# Check if function exists and prepare appropriate command +if ibmcloud ce function get --name "$FUNCTION_NAME" >/dev/null 2>&1; then + DEPLOY_COMMAND="ibmcloud ce function update --name $FUNCTION_NAME --build-source ." +else + DEPLOY_COMMAND="ibmcloud ce function create --name $FUNCTION_NAME --runtime $RUNTIME --build-source ." +fi + +echo "$DEPLOY_COMMAND" +read -r -p "Press Enter to run it, or Ctrl+C to cancel: " + +eval "$DEPLOY_COMMAND" + +echo "[run-angular] Deployment complete!" diff --git a/function-spa/run-react b/function-spa/run-react new file mode 100755 index 00000000..352ca426 --- /dev/null +++ b/function-spa/run-react @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +FUNCTION_NAME="my-spa-react" +RUNTIME="nodejs-24" + +echo "[run-react] Creating new React project: my-spa-react" + +# Create React project with TypeScript, using defaults and non-interactive mode +# Use npm init -y to auto-confirm, then use yes to auto-accept all prompts +npm init -y &>/dev/null +yes "" | npm create vite@latest my-spa-react -- --template react-ts + +echo "[run-react] React project created successfully in my-spa-react/" +echo "[run-react] Building and deploying to IBM Cloud Code Engine..." + +# Check if function exists and prepare appropriate command +if ibmcloud ce function get --name "$FUNCTION_NAME" >/dev/null 2>&1; then + DEPLOY_COMMAND="ibmcloud ce function update --name $FUNCTION_NAME --build-source ." +else + DEPLOY_COMMAND="ibmcloud ce function create --name $FUNCTION_NAME --runtime $RUNTIME --build-source ." +fi + +echo "$DEPLOY_COMMAND" +read -r -p "Press Enter to run it, or Ctrl+C to cancel: " + +eval "$DEPLOY_COMMAND" + +echo "[run-react] Deployment complete!" diff --git a/function-spa/run-svelte b/function-spa/run-svelte new file mode 100755 index 00000000..1115a7af --- /dev/null +++ b/function-spa/run-svelte @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +FUNCTION_NAME="my-spa-svelte" +RUNTIME="nodejs-24" + +echo "[run-svelte] Creating new Svelte project: my-spa-svelte" + +# Create Svelte project without SSR, using defaults and non-interactive mode +# Use npm init -y to auto-confirm, then use yes to auto-accept all prompts +npm init -y &>/dev/null +yes "" | npm create vite@latest my-spa-svelte -- --template svelte + +echo "[run-svelte] Svelte project created successfully in my-spa-svelte/" +echo "[run-svelte] Building and deploying to IBM Cloud Code Engine..." + +# Check if function exists and prepare appropriate command +if ibmcloud ce function get --name "$FUNCTION_NAME" >/dev/null 2>&1; then + DEPLOY_COMMAND="ibmcloud ce function update --name $FUNCTION_NAME --build-source ." +else + DEPLOY_COMMAND="ibmcloud ce function create --name $FUNCTION_NAME --runtime $RUNTIME --build-source ." +fi + +echo "$DEPLOY_COMMAND" +read -r -p "Press Enter to run it, or Ctrl+C to cancel: " + +eval "$DEPLOY_COMMAND" + +echo "[run-svelte] Deployment complete!" +