Skip to content

Commit

Permalink
Add local development mode
Browse files Browse the repository at this point in the history
  • Loading branch information
tgerulaitis committed Jan 31, 2025
1 parent f96b2d6 commit 65890ac
Show file tree
Hide file tree
Showing 8 changed files with 274 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added `cloud-seed run` command to run functions locally with automatic rebuilding on changes. See `README.md` for documentation on how to use this command.
- Cloud Seed commands can now discover the cloudseed.json configuration file for the project when run from within a subdirectory.

## [v4.0.0](https://github.com/Space48/cloud-seed/compare/v3.0.0...v4.0.0)
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ terraform chdir=[buildDir] plan -out=plan
terraform chdir=[buildDir] apply plan
```

## Running functions locally for testing

You can start the local development server for a function by running:

```
npx @space48/cloud-seed run src/myFirstFunction.ts --env environment
```

The development server will build the function and expose it on http://localhost:3000/ so you can trigger it with a HTTP request. Any changes you make to the source code of the function will rebuild it and restart the server, allowing you to keep testing without having to manually run any additional commands.

Tips and tricks:

- The development server does not require access to a cloud project to run the function locally. However, if your function code accesses any cloud resources, such as message queues or databases, you will need to authenticate with the cloud to allow it. For example, you can authenticate with GCP by running the `gcloud auth login` command in gcloud CLI.
- The development server runs a single cloud function in isolation. If you want to test multiple functions, you can run multiple development servers on different ports using the `--port` option. Keep in mind that this won't automatically connect the functions and allow then to interract with each other. Your function code will have to be aware that it is running in development and adjust the endpoints it uses accordingly. You can use the `CLOUD_SEED_ENVIRONMENT` environment variable to write conditional code for running in the development environment.
- Any cloud function can be triggered by a HTTP request, regardless of its type. However, some function types may require the request data to follow a specific format or use a specific encoding. Refer to the official documentation for the cloud service you're using for details on how to format your function input.
- The development server automatically loads the environment variables you have defined in `cloudseed.json` for the provided environment and exposes them to the function. You can use this to configure a dedicated development environment, with appropriate configuration, to be shared by anyone running the project locally. Additionally, any environment variables with the same names defined in your environment will override the values from the configuration file. This is useful to temporarily modify the configuration for testing without having to modify the `cloudseed.json` file.

# How does this work?

Each Cloud Function you wish to define can live anywhere within the `src` directory, how you structure this is up to you.
Expand Down
1 change: 1 addition & 0 deletions bin/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type cliCommand = (argv?: string[]) => void;
const commands: { [command: string]: () => Promise<cliCommand> } = {
build: async () => await import("../cli/build.js").then(i => i.cmdBuild),
list: async () => await import("../cli/list.js").then(i => i.cmdList),
run: async () => await import("../cli/run.js").then(i => i.cmdRun),
};

const args = arg(
Expand Down
61 changes: 61 additions & 0 deletions cli/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import arg, { ArgError } from "arg";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { cliCommand } from "../bin/entrypoint";
import { printAndExit } from "./utils";
import run from "../run";

const argumentSchema = {
"-h": "--help",
"--help": Boolean,

"--env": String,

"-p": "--port",
"--port": Number,
};

export const cmdRun: cliCommand = argv => {
let args: arg.Result<typeof argumentSchema>;

try {
args = arg(argumentSchema, { argv });
} catch (error) {
if (error instanceof ArgError && error.code === "ARG_UNKNOWN_OPTION") {
return printAndExit(error.message);
}

throw error;
}

if (args["--help"]) {
return printAndExit(
`
Usage
$ cloud-seed run <path/to/function.ts> --env=<environment>
Options
--env=<environment> Use the configuration from the specified environment in cloud-seed.json
--help, -h Displays this message
--port=<number>, -p=<number> Specify the port to run the environment on (default: 3000)`,
0,
);
}

const environment = args["--env"];
if (!environment) {
return printAndExit("> Environment is required. Please set the --env flag.");
}

const port = args["--port"] ?? 3000;

const sourceFile = args._[0];
if (!existsSync(resolve(sourceFile))) {
return printAndExit(`> No such file exists: ${sourceFile}`);
}

return run({
environment,
port,
sourceFile,
});
};
94 changes: 94 additions & 0 deletions run/DevelopmentServer/GcpServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { ChildProcess, spawn } from "node:child_process";
import { BaseConfig } from "../../utils/rootConfig";
import { RuntimeConfig } from "../../utils/runtimeConfig";
import { DevelopmentServer } from "./types";

export default class GcpServer implements DevelopmentServer {
private sourceFile: string;
private signatureType: string;
private port: string;
private environmentVariables: { [key: string]: string | undefined } = {};

private serverProcess: ChildProcess | undefined;

constructor(
projectConfig: BaseConfig,
functionConfig: RuntimeConfig,
compiledFile: string,
port: number,
environment: string,
) {
this.sourceFile = compiledFile;
this.signatureType = getFunctionSignatureType(functionConfig);
this.port = port.toString();
this.environmentVariables = getFunctionEnvironmentVariables(projectConfig, environment);
}

public up() {
if (this.serverProcess === undefined) {
this.startServerProcess();
} else {
this.serverProcess.on("exit", () => {
setTimeout(this.startServerProcess.bind(this), 500);
});
this.serverProcess.kill("SIGINT");
}
}

public down() {
if (this.serverProcess !== undefined) {
this.serverProcess.on("exit", () => {
this.serverProcess = undefined;
});
this.serverProcess.kill("SIGINT");
}
}

public isRunning() {
return this.serverProcess !== undefined;
}

private startServerProcess() {
this.serverProcess = spawn(
"npx",
[
"-y",
"@google-cloud/functions-framework",
"--source",
this.sourceFile,
"--target",
"default",
"--signature-type",
this.signatureType,
"--port",
this.port,
],
{
env: this.environmentVariables,
stdio: "inherit",
},
);
}
}

function getFunctionSignatureType(functionConfig: RuntimeConfig): "http" | "event" | "cloudevent" {
switch (functionConfig.type) {
case "event":
case "schedule":
case "firestore":
case "storage":
return "cloudevent";
default:
return "http";
}
}

function getFunctionEnvironmentVariables(projectConfig: BaseConfig, environment: string) {
return {
CLOUD_SEED_ENVIRONMENT: environment,
CLOUD_SEED_PROJECT: projectConfig.cloud.gcp.project,
CLOUD_SEED_REGION: projectConfig.cloud.gcp.region,
...projectConfig.runtimeEnvironmentVariables,
...process.env,
};
}
21 changes: 21 additions & 0 deletions run/DevelopmentServer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { BaseConfig } from "../../utils/rootConfig";
import { RuntimeConfig } from "../../utils/runtimeConfig";
import GcpServer from "./GcpServer";
import { DevelopmentServer } from "./types";

export function getDevelopmentServer(
projectConfig: BaseConfig,
functionConfig: RuntimeConfig,
compiledFile: string,
port: number,
environment: string,
): DevelopmentServer {
switch (functionConfig.cloud) {
case "gcp":
return new GcpServer(projectConfig, functionConfig, compiledFile, port, environment);
default:
throw new Error(
`The development server does not support running functions using the "${functionConfig.cloud}" cloud at this time.`,
);
}
}
16 changes: 16 additions & 0 deletions run/DevelopmentServer/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface DevelopmentServer {
/**
* Start the development server, or restart it if it's already running.
*/
up(): void;

/**
* Stop the development server.
*/
down(): void;

/**
* Check if the server is currently running.
*/
isRunning(): boolean;
}
63 changes: 63 additions & 0 deletions run/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { context } from "esbuild";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { printAndExit } from "../cli/utils";
import { getEsbuildOptions } from "../utils/esbuild";
import { getRootConfig } from "../utils/rootConfig";
import { getRuntimeConfig } from "../utils/runtimeConfig";
import { getDevelopmentServer } from "./DevelopmentServer";

export type RunOptions = {
environment: string;
port: number;
sourceFile: string;
};

export default ({ environment, port, sourceFile }: RunOptions): void => {
const projectConfig = getRootConfig(dirname(sourceFile), { environment });
const functionConfig = getRuntimeConfig(sourceFile, environment);

if (functionConfig === undefined) {
printAndExit(
`> Provided source file "${sourceFile}" does not include a valid function runtime configuration!`,
);

return;
}

const outputDirectory = resolve(projectConfig.buildConfig.outDir);
mkdirSync(outputDirectory, { recursive: true });

const esbuildOptions = getEsbuildOptions(functionConfig, outputDirectory, {
...projectConfig.buildConfig.esbuildOptions,
plugins: [
...(projectConfig.buildConfig.esbuildOptions?.plugins ?? []),
{
name: "Development Server",
setup(build) {
const compiledFile = build.initialOptions.outfile;
if (compiledFile === undefined) return;

const developmentServer = getDevelopmentServer(
projectConfig,
functionConfig,
compiledFile,
port,
environment,
);

build.onEnd(result => {
if (result.errors.length > 0) return;

const action = developmentServer.isRunning() ? "Restarting" : "Starting";

console.log(`Build completed successfully. ${action} the development server...`);
developmentServer.up();
});
},
},
],
});

context(esbuildOptions).then(context => context.watch());
};

0 comments on commit 65890ac

Please sign in to comment.