Scope API
So far we've been living entirely inside Effection's world—generators calling generators, all the way down. But real applications have airlocks: places where you need to cross from regular JavaScript into Effection territory and back.
- Express/Fastify route handlers (callbacks, not generators)
- React component lifecycles (hooks, not operations)
- Callback-based libraries (the old world)
- Test frameworks (need setup/teardown control)
The Scope API is your airlock. It lets you safely move between the two worlds while maintaining structured concurrency guarantees.
The Problem: Callbacks Can't Yield
Express route handlers are regular async functions:
import express from "express";
import { main, sleep } from "effection";
const app = express();
app.get("/slow", async (req, res) => {
// How do we use Effection here?
yield * sleep(1000); // SyntaxError! Not a generator
res.send("Done");
});
We need a way to run operations from inside callback functions.
useScope() - Capture the Current Scope
The useScope() operation gives you a reference to the current scope that you can use later:
import type { Operation, Scope } from "effection";
import { main, useScope, sleep, suspend } from "effection";
await main(function* () {
// Capture the current scope
const scope: Scope = yield* useScope();
// Now we can use scope.run() from any callback!
setTimeout(async () => {
await scope.run(function* (): Operation<void> {
console.log("Running in callback!");
yield* sleep(100);
console.log("Done!");
});
}, 50);
yield* sleep(200);
});
Output:
Running in callback!
Done!
Express Integration
Here's the pattern for Express:
import type { Operation, Scope } from "effection";
import { main, useScope, sleep, ensure, suspend } from "effection";
import express, { Request, Response } from "express";
await main(function* () {
// Capture scope for use in route handlers
const scope: Scope = yield* useScope();
const app = express();
// Route handler uses scope.run()
app.get("/api/data", async (req: Request, res: Response) => {
try {
const result = await scope.run(function* (): Operation<string> {
console.log("Handling request...");
yield* sleep(100); // Simulate async work
return "Hello from Effection!";
});
res.json({ data: result });
} catch (error) {
res.status(500).json({ error: "Internal error" });
}
});
// Slow endpoint that can be cancelled
app.get("/api/slow", async (req: Request, res: Response) => {
try {
await scope.run(function* (): Operation<void> {
yield* sleep(5000);
res.json({ data: "Finally done!" });
});
} catch (error) {
// If the operation was halted, we'll end up here
if (!res.headersSent) {
res.status(503).json({ error: "Request cancelled" });
}
}
});
const server = app.listen(3000);
console.log("Server running on http://localhost:3000");
yield* ensure(() => {
console.log("Shutting down server...");
server.close();
});
yield* suspend();
});
Why scope.run() is Better than run()
When you use scope.run():
- Operations are children of the scope - they get halted when the scope ends
- Context is inherited - operations can access parent context values
- Errors propagate properly - child failures can crash the parent
When you use top-level run():
- Operations are independent - they keep running even if they shouldn't
- No context inheritance - can't access parent context
- Errors are isolated - dangling operations possible
Request-Scoped Operations
Each request can have its own scope:
import type { Operation, Scope, Context } from "effection";
import {
main,
useScope,
createContext,
spawn,
sleep,
ensure,
suspend,
} from "effection";
import express, { Request, Response } from "express";
interface RequestInfo {
id: string;
startTime: number;
}
const RequestContext: Context<RequestInfo> =
createContext<RequestInfo>("request");
let requestCounter = 0;
await main(function* () {
const scope: Scope = yield* useScope();
const app = express();
app.get("/api/user/:id", async (req: Request, res: Response) => {
const requestId = `req-${++requestCounter}`;
await scope.run(function* (): Operation<void> {
// Set request-specific context
yield* RequestContext.set({
id: requestId,
startTime: Date.now(),
});
// Now any operation can access request info
const user = yield* fetchUser(parseInt(req.params.id));
const reqInfo: RequestInfo = yield* RequestContext.expect();
const duration = Date.now() - reqInfo.startTime;
console.log(`[${reqInfo.id}] Completed in ${duration}ms`);
res.json(user);
});
});
const server = app.listen(3000);
console.log("Server running on http://localhost:3000");
yield* ensure(() => server.close());
yield* suspend();
});
function* fetchUser(id: number): Operation<{ id: number; name: string }> {
const req: RequestInfo = yield* RequestContext.expect();
console.log(`[${req.id}] Fetching user ${id}...`);
yield* sleep(100);
return { id, name: `User ${id}` };
}
createScope() - Independent Scopes
Sometimes you need a completely independent scope (e.g., for testing):
import type { Operation, Scope } from "effection";
import { createScope, sleep } from "effection";
async function runTest(): Promise<void> {
// Create a fresh, independent scope
const [scope, destroy]: [Scope, () => Promise<void>] = createScope();
try {
// Run operations in this scope
const result = await scope.run(function* (): Operation<string> {
yield* sleep(100);
return "test passed";
});
console.log(result);
} finally {
// IMPORTANT: Always destroy the scope when done!
await destroy();
}
}
runTest();
Testing with Scopes
Perfect for test frameworks:
import type { Operation, Scope } from "effection";
import { createScope, sleep } from "effection";
describe("MyService", () => {
let scope: Scope;
let destroy: () => Promise<void>;
beforeEach(() => {
[scope, destroy] = createScope();
});
afterEach(async () => {
await destroy();
});
it("should do something async", async () => {
const result = await scope.run(function* (): Operation<number> {
yield* sleep(10);
return 42;
});
expect(result).toBe(42);
});
it("handles cancellation", async () => {
const task = scope.run(function* (): Operation<void> {
yield* sleep(10000); // Long operation
});
// Cancel it
await destroy();
// Task was halted, not left dangling
});
});
useAbortSignal() - Integration with fetch
Many APIs accept an AbortSignal for cancellation:
import type { Operation } from "effection";
import { main, useAbortSignal, race, sleep, call } from "effection";
function* fetchWithEffection(url: string): Operation<Response> {
// Get an AbortSignal tied to this operation's lifetime
const signal: AbortSignal = yield* useAbortSignal();
// Pass it to fetch - request will be cancelled if operation halts!
const response = yield* call(() => fetch(url, { signal }));
return response;
}
await main(function* () {
try {
// Race fetch against timeout
const response: Response = yield* race([
fetchWithEffection("https://httpbin.org/delay/10"), // 10 second delay
timeout(2000), // 2 second timeout
]);
console.log("Got response:", response.status);
} catch (error) {
console.log("Request timed out or failed");
}
});
function* timeout(ms: number): Operation<never> {
yield* sleep(ms);
throw new Error(`Timeout after ${ms}ms`);
}
When the timeout wins, the fetch operation is halted, and the AbortSignal fires, actually cancelling the HTTP request!
Pattern: Scope Provider Resource
Create a resource that provides a scope for request handling:
import type { Operation, Scope } from "effection";
import { main, resource, useScope, sleep, suspend } from "effection";
import express, { Express, Request, Response } from "express";
import { Server } from "http";
interface ExpressApp {
app: Express;
scope: Scope;
port: number;
}
function useExpressApp(port: number): Operation<ExpressApp> {
return resource<ExpressApp>(function* (provide) {
const scope: Scope = yield* useScope();
const app = express();
const server: Server = app.listen(port);
console.log(`Express listening on port ${port}`);
try {
yield* provide({ app, scope, port });
} finally {
console.log("Closing Express server...");
server.close();
}
});
}
await main(function* () {
const { app, scope, port }: ExpressApp = yield* useExpressApp(3000);
app.get("/", async (req: Request, res: Response) => {
await scope.run(function* (): Operation<void> {
yield* sleep(100);
res.send("Hello from Effection!");
});
});
console.log(`Server ready at http://localhost:${port}`);
yield* suspend();
});
Key Takeaways
The Scope API is your airlock between JavaScript and Effection:
useScope()captures the current scope - grab a reference before entering the airlockscope.run()runs operations as children - they're still part of the structured treecreateScope()creates independent scopes - a fresh airlock for testing- Always destroy created scopes - seal the airlock when you're done
useAbortSignal()integrates with fetch - cancellation that actually cancels