diff --git a/src/commands/sites/build.ts b/src/commands/sites/build.ts index bd0c733..2143046 100644 --- a/src/commands/sites/build.ts +++ b/src/commands/sites/build.ts @@ -30,15 +30,14 @@ export const run = async (options: { // check for and store unmodified wasm file name to change later const buildConfig = !debug ? build_release : build + const deployConfig = deployment const buildDir = resolve(path, buildConfig.dir || '.bls') const buildName = buildConfig.entry ? buildConfig.entry.replace('.wasm', '') : name const wasmName = buildConfig.entry || `${name}.wasm` const wasmArchive = `${buildName}.tar.gz` - // Rebuild function if requested - if (!fs.existsSync(resolve(buildDir, wasmName)) || rebuild) { - buildSiteWasm(wasmName, buildDir, path, buildConfig, debug) - } else if (fs.existsSync(resolve(buildDir, wasmName)) && !rebuild) { + // Bail if file is present and rebuild is not requested + if (fs.existsSync(resolve(buildDir, wasmName)) && !rebuild) { return } @@ -48,6 +47,9 @@ export const run = async (options: { content_type ) + // Build site WASM + await buildSiteWasm(wasmName, buildDir, path, buildConfig, debug) + // Create a WASM archive const archive = createWasmArchive(buildDir, wasmArchive, wasmName) const checksum = generateChecksum(archive) @@ -60,6 +62,10 @@ export const run = async (options: { result_type: "string", }) + if (deployConfig) { + wasmManifest.permissions = deployConfig.permissions || [] + } + // Store manifest fs.writeFileSync(`${buildDir}/manifest.json`, JSON.stringify(wasmManifest)) diff --git a/src/commands/sites/index.ts b/src/commands/sites/index.ts index e4f8d0a..0adf1d3 100644 --- a/src/commands/sites/index.ts +++ b/src/commands/sites/index.ts @@ -66,13 +66,6 @@ export function sitesCli(yargs: Argv) { type: 'string', default: undefined }) - .option('debug', { - alias: 'd', - description: 'Add a debug flag to the function build', - type: 'boolean', - default: false - }) - .group(['debug'], 'Options:') }, (argv) => { runBuild(argv as any) @@ -89,6 +82,12 @@ export function sitesCli(yargs: Argv) { type: 'string', default: undefined }) + .option('rebuild', { + description: 'Rebuild the funciton', + type: 'boolean', + default: true + }) + .group(['rebuild'], 'Options:') }, (argv) => { runPreview(argv) diff --git a/src/commands/sites/preview.ts b/src/commands/sites/preview.ts index c592f69..e07d8d5 100644 --- a/src/commands/sites/preview.ts +++ b/src/commands/sites/preview.ts @@ -46,7 +46,7 @@ export const run = async (options: any) => { const { build, build_release } = parseBlsConfig() // Execute the build command - runBuild({ path, debug, rebuild }) + await runBuild({ path, debug, rebuild }) // check for and store unmodified wasm file name to change later const buildConfig = !debug ? build_release : build @@ -94,6 +94,56 @@ export const run = async (options: any) => { }) fastify.get("*", async (request, reply) => { + let qs = '' + let headerString = '' + let requestPath = decodeURIComponent(request.url.trim()) + + if (requestPath.includes('?')) { + qs = requestPath.split('?')[1] + requestPath = requestPath.split('?')[0] + } + + if (request.headers) { + headerString = Object.entries(request.headers) + .map(([key, value]) => `${key}=${value}`) + .join('&') + } + + let envString = '' + let envVars = [] as string[] + let envVarsKeys = [] as string[] + + if (!!options.env) { + // Validate environment variables + const vars = typeof options.env === 'string' ? [options.env] : options.env + vars.map((v: string) => { + const split = v.split('=') + if (split.length !== 2) return + + envVars.push(v) + envVarsKeys.push(split[0]) + }) + } + + envVars.push(`BLS_REQUEST_PATH="${requestPath}"`) + envVars.push(`BLS_REQUEST_QUERY="${qs}"`) + envVars.push(`BLS_REQUEST_METHOD="${request.method}"`) + envVars.push(`BLS_REQUEST_HEADERS="${headerString}"`) + envVarsKeys.push('BLS_REQUEST_PATH') + envVarsKeys.push('BLS_REQUEST_QUERY') + envVarsKeys.push('BLS_REQUEST_METHOD') + envVarsKeys.push('BLS_REQUEST_HEADERS') + + if (request.body) { + envVars.push(`BLS_REQUEST_BODY="${JSON.stringify(request.body)}"`) + envVarsKeys.push('BLS_REQUEST_BODY') + } + + // Include environment string if there are variables + if (envVars.length > 0) { + envString = `env ${envVars.join(' ')} BLS_LIST_VARS=\"${envVarsKeys.join(';')}\"` + } + const result = execSync(`echo "${decodeURIComponent(request.url.trim())}" | ${envString} ${runtimePath} ${manifestPath}`, { cwd: path, maxBuffer: (10000 * 1024) diff --git a/src/commands/sites/shared.ts b/src/commands/sites/shared.ts index d8ecf63..ed4ab2f 100644 --- a/src/commands/sites/shared.ts +++ b/src/commands/sites/shared.ts @@ -4,7 +4,18 @@ import fs, { existsSync } from "fs" import path, { resolve } from "path" import { execSync } from "child_process" import { IBlsBuildConfig } from "../function/interfaces" -import { getDirectoryContents } from '../../lib/dir' +import { copyFileSync, getDirectoryContents } from '../../lib/dir' +import { slugify } from '../../lib/strings' +import { getTsExportedFunctions } from '../../lib/sourceFile' + +interface IFunctionRoute { + path: string + url: string + nameSlug: string + name: string + wildcard: string | null + exportedFunctions: string[] +} /** * Build a WASM project based on the build config. @@ -21,7 +32,7 @@ export const buildSiteWasm = async ( path: string, buildConfig: IBlsBuildConfig, debug: boolean) => { - console.log(`${Chalk.yellow(`Building:`)} function ${wasmName} in ${buildDir}...`) + console.log(`${Chalk.yellow(`Building:`)} site ${wasmName} in ${buildDir}...`) if (buildConfig.command) { try { @@ -40,23 +51,17 @@ export const buildSiteWasm = async ( } } catch {} - // Run build command + // Run framework build command execSync(buildConfig.command, { cwd: path, - stdio: "inherit", + stdio: "inherit" }) } // Compile wasm for blockless site const publicDir = resolve(path, buildConfig.public_dir || 'out') - await doCompile(wasmName, publicDir, buildDir) - // Modify - // TODO: See if we can shift this fallback to our template's build function - const defaultWasm = debug ? "debug.wasm" : "release.wasm" - if (existsSync(resolve(buildDir, defaultWasm))) { - execSync(`cp ${defaultWasm} ${wasmName}`, { cwd: buildDir, stdio: "inherit" }) - } + return await doCompile(wasmName, publicDir, buildDir) } /** @@ -67,19 +72,22 @@ export const buildSiteWasm = async ( * @returns */ const doCompile = (name: string, source: string, dest: string = path.resolve(process.cwd(), '.bls')) => { - return new Promise(async (resovle, reject) => { + return new Promise(async (resovle, reject) => { try { // Pack files and generate a tempory assembly script - const { dir } = generateCompileDirectory(source) + console.log('') + const { dir } = generateCompileDirectory(source, dest) + console.log('') + console.log(`${Chalk.yellow(`Generating site:`)} at ${dest} ...`) execSync(`npm install && npm run build -- -o ${path.resolve(dest, name)}`, { cwd: dir }) + console.log('') // Clear temp source files fs.rmSync(dir, { recursive: true, force: true }) - // Resolve release wasm resovle(path.resolve(dest, name)) } catch (error) { reject(error) @@ -108,26 +116,142 @@ const packFiles = (source: string) => { } /** - * Generate a + * Read source folder's routes and pack dynamic routes + * + * @param source + */ +const packRoutes = (source: string, dest: string) => { + const functionsPath = path.resolve(source, '..', 'assembly', 'routes') + const routes = [] as IFunctionRoute[] + + /** + * Traverse through the given directory recursively + * and fill in route details + * @param dirPath + */ + const traverseRoutes = (dirPath: string) => { + const files = fs.readdirSync(dirPath) + + files.forEach(file => { + const filePath = path.join(dirPath, file) + + if (fs.statSync(filePath).isDirectory()) { traverseRoutes(filePath) } + else if (file.endsWith('.ts')) { + // Check whether exported functions exist + const exportedFunctions = getTsExportedFunctions(filePath) + if (!exportedFunctions || exportedFunctions.length === 0) return + + // Fetch file name and path + const name = file.replace('.ts', '') + let relativePath = filePath.replace(functionsPath, '') + let url = relativePath.replace('.ts', '') + + // Test wheather the route is a wildcard or a static route + let wildcardParam = null + const isWildcard = /^\[.*\]$/.test(name) + + // If the route is a wildcard route, update path references + if (isWildcard) { + const matches = name.match(/\[(.*?)\]/g) + if (matches && matches.length > 0) { + wildcardParam = matches[0].slice(1, -1) + relativePath = relativePath.replace(matches[0], `wildcard_${wildcardParam}`) + url = url.replace(`wildcard_${wildcardParam}`, '').replace(name, '') + } + } + + // Handle index routes without the index path + if (url.endsWith('/index')) { + url = url.replace(new RegExp('/index$'), '') + } + + // Clear out all leading slashes + if (relativePath.startsWith('/')) { + relativePath = relativePath.substring(1); + } + + // Fill in the route details + routes.push({ + name: slugify(name), + path: relativePath, + url, + nameSlug: relativePath.replace('.ts', '').replace('[', '').replace(']', '').replace(new RegExp(path.sep, 'g'), '__'), + wildcard: isWildcard ? wildcardParam : null, + exportedFunctions: getTsExportedFunctions(filePath) + }) + + // Move route to the build folder + console.log(`${Chalk.yellow(`Compiling route:`)} ${relativePath} ...`) + copyFileSync(filePath, path.resolve(dest, relativePath)) + } + }) + } + + // Look through all routes and fill in route definations + if (fs.existsSync(functionsPath)) { + traverseRoutes(functionsPath) + } + + // Sort routes to place all wildcard routes at the end, + // Allowing for static nested routes along with wildcards + routes.sort((r1, r2) => { + if (!!r1.wildcard && !r2.wildcard) { return 1 } + else if (!r1.wildcard && !!r2.wildcard) { return -1 } + else { return 0 } + }) + + return routes +} + +/** + * Generate assets and directory for compiling the blockless site * * @param source * @returns */ -const generateCompileDirectory = (source: string): { dir: string, file: string } => { +const generateCompileDirectory = (source: string, dest: string): { dir: string, file: string } => { + // Create working directories + if (!fs.existsSync(dest)) fs.mkdirSync(dest) + const buildDir = path.resolve(dest, 'build') + const buildEntryDir = path.resolve(buildDir, 'entry') + const buildRoutesDir = path.resolve(buildEntryDir, 'routes') + if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir) + if (fs.existsSync(buildEntryDir)) fs.rmSync(buildEntryDir, { recursive: true }) + fs.mkdirSync(buildEntryDir) + if (!fs.existsSync(buildRoutesDir)) fs.mkdirSync(buildRoutesDir) + + // Prepare static files and dynamic routes const sources = packFiles(source) + const routes = packRoutes(dest, buildRoutesDir) + let assetsContent = '' + let routesImport = '' + let routesContent = '' for (const s in sources) { assetsContent += `assets.set("${s}", "${sources[s]}")\n` } + for (const r in routes) { + const route = routes[r] + routesImport += `import * as bls_route_${route.nameSlug} from './routes/${route.path}'\n` + + route.exportedFunctions.map(f => { + if (!!route.wildcard) { + routesContent += `if (method === '${f}' && validateWildcardRoute(req.url, '${route.url}')) { req.query.set('${route.wildcard}', extractWildcardParam(req.url, '${route.url}')); response = bls_route_${route.nameSlug}.${f}(req).toString() } else ` + } else { + routesContent += `if (method === '${f}' && validateRoute(req.url, '${route.url}')) { response = bls_route_${route.nameSlug}.${f}(req).toString() } else ` + } + }) + } + const packageJsonScript = `{ "scripts": { "build": "asc index.ts --config asconfig.json" }, "dependencies": { "@assemblyscript/wasi-shim": "^0.1.0", - "@blockless/sdk": "^1.1.0", + "@blockless/sdk": "^1.1.2", "as-wasi": "^0.6.0" }, "devDependencies": { @@ -138,88 +262,97 @@ const generateCompileDirectory = (source: string): { dir: string, file: string } const asConfigScript = `{ "extends": "./node_modules/@assemblyscript/wasi-shim/asconfig.json", "targets": { - "debug": { - "outFile": "debug.wasm", - "sourceMap": true, - "debug": true - }, "release": { "outFile": "release.wasm", - "sourceMap": true, "optimizeLevel": 3, "shrinkLevel": 0, "converge": false, "noAssert": false } - }, - "options": { - "bindings": "esm" } }` const script = ` - import { http } from "@blockless/sdk/assembly" - const assets = new Map() - - ${assetsContent} +import { http } from "@blockless/sdk/assembly" +const assets = new Map() - /** - * HTTP Component serving static html text - * - */ - http.HttpComponent.serve((req: http.Request) => { - let response: string = '404 not found.' - let status: u32 = 404 - let contentType: string = 'text/html' +// Assets +${assetsContent} - let url = req.url - let urlWithoutSlash = url.endsWith('/') ? url.slice(0, -1) : url +// Routes +${routesImport} - // Serve the index file for the homepage - if (url === '/' && assets.has('/index.html')) { - url = '/index.html' - } +// Helper functions +function validateRoute(url: string, route: string): boolean { + return url === route +} - // Serve nested index.html for any folder route - // Serve matching html for a non html route - if (!assets.has(urlWithoutSlash) && assets.has(urlWithoutSlash + '/index.html')) { - url = urlWithoutSlash + '/index.html' - } else if (!assets.has(urlWithoutSlash) && assets.has(urlWithoutSlash + '.html')) { - url = urlWithoutSlash + '.html' - } - - // Match assets and serve data - if (assets.has(url)) { - // Parse content type and format - const content = assets.get(url) - - if (content.startsWith('data:')) { - const matchString = content.replace('data:', '') - const matchTypeSplit = matchString.split(';') - - contentType = matchTypeSplit[0] - } +function validateWildcardRoute(url: string, routePart: string): boolean { + const part = url.replace(routePart, '') + return url.startsWith(routePart) && !!part && part.indexOf('/') === -1 +} + +function extractWildcardParam(url: string, routePart: string): string { + return url.replace(routePart, '') +} - response = assets.get(url) - status = 200 - } else if (assets.has('/404.html')) { - response = assets.get('/404.html') +/** + * HTTP Component serving static html text + * + */ +http.HttpComponent.serve((req: http.Request) => { + let response: string = '404 not found.' + let status: u32 = 404 + let contentType: string = 'text/html' + let method: string = req.method || 'GET' + + let url = req.url + let urlWithoutSlash = url.endsWith('/') ? url.slice(0, -1) : url + + // Serve the index file for the homepage + if (url === '/' && assets.has('/index.html')) { + url = '/index.html' + } + + // Serve nested index.html for any folder route + // Serve matching html for a non html route + if (!assets.has(urlWithoutSlash) && assets.has(urlWithoutSlash + '/index.html')) { + url = urlWithoutSlash + '/index.html' + } else if (!assets.has(urlWithoutSlash) && assets.has(urlWithoutSlash + '.html')) { + url = urlWithoutSlash + '.html' + } + + // Match assets and serve data + ${routesContent}if (assets.has(url)) { + // Parse content type and format + const content = assets.get(url) + + if (content.startsWith('data:')) { + const matchString = content.replace('data:', '') + const matchTypeSplit = matchString.split(';') + + contentType = matchTypeSplit[0] } - return new http.Response(response) - .header('Content-Type', contentType) - .status(status) - }) - ` - const tempDir = fs.mkdtempSync(path.resolve(os.tmpdir(), 'bls-')) - const filePath = path.resolve(tempDir, 'index.ts') + response = assets.get(url) + status = 200 + } else if (assets.has('/404.html')) { + response = assets.get('/404.html') + } + + return new http.Response(response) + .header('Content-Type', contentType) + .status(status) +})` + + const filePath = path.resolve(buildEntryDir, 'index.ts') fs.writeFileSync(filePath, script) - fs.writeFileSync(path.resolve(tempDir, 'asconfig.json'), asConfigScript) - fs.writeFileSync(path.resolve(tempDir, 'package.json'), packageJsonScript) + fs.writeFileSync(path.resolve(buildEntryDir, 'asconfig.json'), asConfigScript) + fs.writeFileSync(path.resolve(buildEntryDir, 'package.json'), packageJsonScript) return { - dir: tempDir, + dir: buildEntryDir, file: filePath } } \ No newline at end of file diff --git a/src/lib/dir.ts b/src/lib/dir.ts index 0f5a1a0..dee8833 100644 --- a/src/lib/dir.ts +++ b/src/lib/dir.ts @@ -1,19 +1,31 @@ import fs from 'fs' import { mimeTypes } from '../store/constants' +import path from 'path' export const getDirectoryContents = (dir: string, results = {} as { [key: string]: string }) => { - fs.readdirSync(dir).forEach(file => { - file = dir + '/' + file; - const stat = fs.statSync(file); - if (stat && stat.isDirectory()) { - results = getDirectoryContents(file, results); - } else { - const extension = `.${file.split(".").pop()}`; - const contents = fs.readFileSync(file, { encoding: "base64" }); + fs.readdirSync(dir).forEach((file) => { + file = dir + '/' + file + const stat = fs.statSync(file) + if (stat && stat.isDirectory()) { + results = getDirectoryContents(file, results) + } else { + const extension = `.${file.split('.').pop()}` + const contents = fs.readFileSync(file, { encoding: 'base64' }) - results[file] = `data:${mimeTypes[extension]};base64,${contents}` - } - }); + results[file] = `data:${mimeTypes[extension]};base64,${contents}` + } + }) - return results; -} \ No newline at end of file + return results +} + +export function copyFileSync(sourcePath: string, targetPath: string) { + const data = fs.readFileSync(sourcePath) + + const targetDir = path.dirname(targetPath) + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }) + } + + fs.writeFileSync(targetPath, data) +} diff --git a/src/lib/sourceFile.ts b/src/lib/sourceFile.ts new file mode 100644 index 0000000..f5ab5a3 --- /dev/null +++ b/src/lib/sourceFile.ts @@ -0,0 +1,28 @@ +import fs from 'fs' +import ts from 'typescript' + +export function getTsExportedFunctions(fileName: string) { + const allowedVerbs = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] + + const fileContents = fs.readFileSync(fileName, "utf-8") + const sourceFile = ts.createSourceFile(fileName, fileContents, ts.ScriptTarget.Latest) + + const exportedFunctions: string[] = [] + + function visitNode(node: any) { + if ( + ts.isFunctionDeclaration(node) && + node.name && + ts.isIdentifier(node.name) && + allowedVerbs.includes(node.name.escapedText as string) + ) { + exportedFunctions.push(node.name.escapedText as string) + } + + ts.forEachChild(node, visitNode) + } + + visitNode(sourceFile) + + return exportedFunctions +} \ No newline at end of file