Skip to content
Draft
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
15 changes: 11 additions & 4 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ project is to provide modules for:
3. Dispatching event payloads to individual functions by callback ID and running
them (`src/dispatch-payload.ts`)

This library has two modes of operation:
This library has multiple modes of operation:

1. Using `mod.ts` as the entrypoint, a directory containing function code files
to be loaded at runtime must be provided as an argument. This directory must
Expand All @@ -22,14 +22,19 @@ This library has two modes of operation:
contain a `manifest.json`, `manifest.ts` or `manifest.js` file, which in turn
must contain function definitions that include a `source_file` property. This
property is used to determine which function to load and run at runtime.
3. Using `self-hosted-socket-mode.ts` as the entrypoint. This establishes a
persistent WebSocket connection to Slack using Socket Mode and maintains a
long-running process that listens for `function_executed` events. It expects
a similar setup in the current working directory as the `local-run.ts`
entrypoint.

Regardless of which mode of operation used, each runtime definition for a
function is specified in its own file and must be the default export.

## Usage

By default, your Slack app has a `/slack.json` file that defines a `get-hooks`
hook. The Slack CLI will automatically use the version of the
By default, your Slack app has a `.slack/hooks.json` file that defines a
`get-hooks` hook. The Slack CLI will automatically use the version of the
`deno-slack-runtime` that is specified by the version of the `get-hooks` script
that you're using. To use this library via the Slack CLI out of the box, use the
`slack run` command in your terminal. This will automatically run the `start`
Expand All @@ -42,7 +47,7 @@ You also have the option to
You can change the script that runs by specifying a new script for the `start`
command. For instance, if you wanted to point to your local instance of this
repo, you could accomplish that by adding a `start` command to your
`/slack.json` file and setting it to the following:
`.slack/hooks.json` file and setting it to the following:

```json
{
Expand All @@ -60,6 +65,8 @@ operating this library in:
`deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/deno_slack_runtime@0.1.1/mod.ts ./<required-function-directory>`
2. Local project with a manifest file:
`deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/deno_slack_runtime@0.1.1/local-run.ts`
3. Self-hosted socket mode:
`deno run -q --config=deno.jsonc --allow-read --allow-net --allow-run --allow-env --allow-sys=osRelease https://deno.land/x/deno_slack_runtime@0.1.1/self-hosted-socket-mode.ts`

⚠️ Don't forget to update the version specifier in the URL inside the above
commands to match the version you want to test! You can also drop the `@` and
Expand Down
7 changes: 7 additions & 0 deletions src/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@ export { getManifest } from "https://deno.land/x/deno_slack_hooks@1.4.0/get_mani
export { parse } from "https://deno.land/std@0.99.0/flags/mod.ts";
export { getProtocolInterface } from "https://deno.land/x/deno_slack_protocols@0.0.2/mod.ts";
export type { Protocol } from "https://deno.land/x/deno_slack_protocols@0.0.2/types.ts";

// Dependencies for self-hosted-socket-mode.ts
export { SocketModeClient } from "npm:@slack/socket-mode@2.0.5";

// Logging dependencies for self-hosted entrypoints
export type { Logger } from "npm:@slack/logger@4.0.0";
export { ConsoleLogger, LogLevel } from "npm:@slack/logger@4.0.0";
1 change: 1 addition & 0 deletions src/local-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const getCommandline = function (
"--config=deno.jsonc",
"--allow-read",
"--allow-env",
"--allow-sys=osRelease",
Copy link
Copy Markdown
Contributor Author

@vegeris vegeris Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why CLI-managed socket mode does not already require this permission to be set :not-sure:

The 'self hosted' script executes functions the same way as the 'local run' script; we run local-run-function.ts as a subprocess, which dispatches function execution to run-function.ts, which should ultimately call functions.completeSuccess or functions.completeFailure using BaseSlackAPIClient. As per Cursor: BaseSlackAPIClient uses Node’s os.release(), which in Deno is implemented with Deno.osRelease() and requires --allow-sys=osRelease

The local run start hook does run it with that permission, but the flags for the parent process don't get passed into the subprocess :confused_math_lady:
deno run -q --config=deno.jsonc --allow-read --allow-net --allow-run --allow-env --allow-sys=osRelease https://deno.land/x/deno_slack_runtime@1.1.3/local-run.ts

];

const allowedDomains = manifest.outgoing_domains ?? [];
Expand Down
211 changes: 211 additions & 0 deletions src/self-hosted-socket-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import {
ConsoleLogger,
getManifest,
getProtocolInterface,
type Logger,
LogLevel,
Protocol,
SocketModeClient,
} from "./deps.ts";
import { getCommandline } from "./local-run.ts";
import type { InvocationPayload } from "./types.ts";

export interface SocketModeRunOptions {
appToken: string;
logger?: Logger;
logLevel?: LogLevel;
slackApiUrl?: string;
}

/**
* @description Runs a Slack workflow app in Socket Mode by establishing a WebSocket connection to Slack.
*/
export const runWithSocketMode = async function (
create: typeof getManifest,
hookCLI: Protocol,
options: SocketModeRunOptions,
): Promise<void> {
const { appToken, logLevel = LogLevel.INFO, slackApiUrl } = options;

// Set up logger
const logger = options.logger ?? (() => {
const defaultLogger = new ConsoleLogger();
defaultLogger.setLevel(logLevel);
return defaultLogger;
})();

// Load the manifest to get function definitions
const workingDirectory = Deno.cwd();
const manifest = await create(workingDirectory);

if (!manifest.functions) {
logger.error(
`No function definitions found in the manifest`,
);
throw new Error(
`No function definitions found in the manifest`,
);
}

const devDomain = slackApiUrl ? new URL(slackApiUrl).hostname : "";
let denoExecutablePath = "deno";
try {
denoExecutablePath = Deno.execPath();
} catch (e) {
logger.warn("Could not get Deno executable path, using 'deno'", e);
}
// Run the function in a subprocess with restricted permissions
const subprocessCommand = getCommandline(
Deno.mainModule,
manifest,
devDomain,
hookCLI,
);

const clientOptions = slackApiUrl ? { slackApiUrl } : undefined;
const client = new SocketModeClient({
appToken,
logLevel,
logger,
Copy link
Copy Markdown
Contributor Author

@vegeris vegeris Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local run script uses the 'hook CLI protocol' for logging, but since it's not applicable here we just use the Logger that the socket mode client accepts

clientOptions,
});

// Listen for incoming events
client.on("slack_event", async ({ body, ack, retry_num, retry_reason }) => {
logger.debug("Received event:", JSON.stringify(body, null, 2));
try {
// deno-lint-ignore no-explicit-any
const payload: InvocationPayload<any> = {
body,
context: {
bot_access_token: body.event.bot_access_token,
team_id: body.team_id,
variables: Deno.env.toObject(),
},
};

// Add retry information if present
if (retry_num !== undefined && retry_num > 0) {
logger.warn(
`Retrying event (attempt ${retry_num})${
retry_reason ? `: ${retry_reason}` : ""
}`,
);
}

// Run the function in a subprocess with the same --allow-net restriction as local-run.ts (manifest.outgoing_domains)
const commander = new Deno.Command(denoExecutablePath, {
args: subprocessCommand,
stdin: "piped",
stdout: "piped",
stderr: "piped",
cwd: workingDirectory,
});
const subprocess = commander.spawn();
const payloadJson = JSON.stringify(payload);
const writer = subprocess.stdin.getWriter();
await writer.write(new TextEncoder().encode(payloadJson));
await writer.close();

const output = await subprocess.output();
const stdout = new TextDecoder().decode(output.stdout).trim();
const stderr = new TextDecoder().decode(output.stderr);

if (!output.success) {
logger.error(
`Function subprocess failed (exit code ${output.code}). stderr: ${
stderr || "(none)"
}`,
);
await ack();
return;
}

if (stdout) {
logger.info(`Function response: ${stdout}`);
}

await ack({});
logger.debug("Event processed and acknowledged");
} catch (error) {
logger.error("Error processing event:", error);
await ack();
}
});

// Handle connection lifecycle events
client.on("connected", () => {
logger.info("✅ Connected to Slack via Socket Mode");
});

client.on("disconnected", () => {
logger.warn("⚠️ Disconnected from Slack");
});

client.on("reconnecting", () => {
logger.info("🔄 Reconnecting to Slack...");
});

client.on("error", (error: Error) => {
logger.error("❌ Socket Mode error:", error);
});

// Start the Socket Mode connection
logger.info("🚀 Starting Socket Mode client...");
try {
await client.start();
} catch (error) {
logger.error("Failed to start Socket Mode client:", error);
throw error;
}
logger.info("⚡️ Socket Mode runtime is running and listening for events");

// Keep the process running
// In Deno, we can use Deno.addSignalListener to handle graceful shutdown
const handleShutdown = async (signal: string) => {
logger.info(`Received ${signal}, shutting down gracefully...`);
try {
await client.disconnect();
logger.info("Disconnected from Slack");
Deno.exit(0);
} catch (error) {
logger.error("Error during shutdown:", error);
Deno.exit(1);
}
};

Deno.addSignalListener("SIGINT", () => handleShutdown("SIGINT"));
Deno.addSignalListener("SIGTERM", () => handleShutdown("SIGTERM"));
};

if (import.meta.main) {
const appToken = Deno.env.get("SLACK_APP_TOKEN");
if (!appToken) {
console.error(
"Error: SLACK_APP_TOKEN environment variable is required for Socket Mode",
);
Deno.exit(1);
}

const logLevelStr = Deno.env.get("SLACK_LOG_LEVEL") || "INFO";
const logLevel = LogLevel[logLevelStr as keyof typeof LogLevel] ||
LogLevel.INFO;

const slackApiUrl = Deno.env.get("SLACK_API_URL");

const hookCLI = getProtocolInterface(Deno.args);

try {
await runWithSocketMode(
getManifest,
hookCLI,
{
appToken,
logLevel,
slackApiUrl,
},
);
} catch {
Deno.exit(1);
}
}
Loading