Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions function-spa/README.md
Original file line number Diff line number Diff line change
@@ -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".
76 changes: 76 additions & 0 deletions function-spa/build-spa.sh
Original file line number Diff line number Diff line change
@@ -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/"

155 changes: 155 additions & 0 deletions function-spa/main.js
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions function-spa/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading