diff --git a/content/astro.config.ts b/content/astro.config.ts index e0de8b437..d95501c83 100644 --- a/content/astro.config.ts +++ b/content/astro.config.ts @@ -18,11 +18,11 @@ import { pluginOpenInPlayground } from "./src/plugins/expressive-code/open-in-pl import { pluginTwoslashPagefind } from "./src/plugins/expressive-code/twoslash-pagefind" import { effectPlaygroundPlugin } from "./src/plugins/starlight/playground" import { effectPodcastPlugin } from "./src/plugins/starlight/podcast" +import { effectLearnPlugin } from "./src/plugins/starlight/learn" import { monacoEditorPlugin } from "./src/plugins/vite/monaco-editor" const VERCEL_PREVIEW_DOMAIN = - process.env.VERCEL_ENV !== "production" && - process.env.VERCEL_BRANCH_URL + process.env.VERCEL_ENV !== "production" && process.env.VERCEL_BRANCH_URL const domain = VERCEL_PREVIEW_DOMAIN || "effect.website" @@ -208,6 +208,7 @@ export default defineConfig({ starlightLinksValidator({ exclude: ["/events/effect-days*"] }), + effectLearnPlugin(), effectPlaygroundPlugin({ pattern: "/play" }), diff --git a/content/package.json b/content/package.json index c29d7a62f..8527fa441 100644 --- a/content/package.json +++ b/content/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@sentry/opentelemetry": "^8.50.0", "@tanstack/react-table": "^8.20.5", + "@types/express": "^5.0.0", "@types/json-schema": "^7.0.15", "@types/node": "^22.9.3", "@types/react": "^18.3.12", diff --git a/content/pnpm-lock.yaml b/content/pnpm-lock.yaml index e724cd44e..eee07fae7 100644 --- a/content/pnpm-lock.yaml +++ b/content/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@tanstack/react-table': specifier: ^8.20.5 version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/express': + specifier: ^5.0.0 + version: 5.0.0 '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 @@ -2272,6 +2275,12 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -2284,9 +2293,18 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/express-serve-static-core@5.0.5': + resolution: {integrity: sha512-GLZPrd9ckqEBFMcVM/qRFAP0Hg3qiVEojgEFsx/N/zKXsBzbGF6z5FBDpZ0+Xhp1xr+qRZYjfGr1cWHB9oFHSA==} + + '@types/express@5.0.0': + resolution: {integrity: sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2296,6 +2314,9 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} @@ -2314,6 +2335,12 @@ packages: '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + '@types/qs@6.9.18': + resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.1': resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==} @@ -2323,6 +2350,12 @@ packages: '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} @@ -7034,6 +7067,15 @@ snapshots: dependencies: '@babel/types': 7.26.0 + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.9.3 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.9.3 + '@types/cookie@0.6.0': {} '@types/debug@4.1.12': @@ -7046,10 +7088,26 @@ snapshots: '@types/estree@1.0.6': {} + '@types/express-serve-static-core@5.0.5': + dependencies: + '@types/node': 22.9.3 + '@types/qs': 6.9.18 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express@5.0.0': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 5.0.5 + '@types/qs': 6.9.18 + '@types/serve-static': 1.15.7 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 + '@types/http-errors@2.0.4': {} + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -7058,6 +7116,8 @@ snapshots: '@types/mdx@2.0.13': {} + '@types/mime@1.3.5': {} + '@types/ms@0.7.34': {} '@types/nlcst@2.0.3': @@ -7074,6 +7134,10 @@ snapshots: '@types/prop-types@15.7.13': {} + '@types/qs@6.9.18': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.1': dependencies: '@types/react': 18.3.12 @@ -7087,6 +7151,17 @@ snapshots: dependencies: '@types/node': 22.9.3 + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.9.3 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 22.9.3 + '@types/send': 0.17.4 + '@types/shimmer@1.2.0': {} '@types/unist@2.0.11': {} diff --git a/content/src/components/plugins/learn/Tutorial.astro b/content/src/components/plugins/learn/Tutorial.astro new file mode 100644 index 000000000..a0860ccfa --- /dev/null +++ b/content/src/components/plugins/learn/Tutorial.astro @@ -0,0 +1,38 @@ +--- +import type { GetStaticPathsResult, InferGetStaticPropsType } from "astro" +import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro" +import type { StarlightPageProps } from "@astrojs/starlight/props" +import { getTutorialEntries } from "@/lib/content/tutorial" + +export const prerender = true + +export async function getStaticPaths() { + const entries = await getTutorialEntries() + return entries.map((entry) => ({ + params: { + prefix: "learn", + tutorial: entry.slug.replace("learn/tutorial", "") + }, + props: { + tutorial: entry.data, + render: entry.render + } + })) satisfies GetStaticPathsResult +} + +type Props = InferGetStaticPropsType + +const pageProps: StarlightPageProps = { + frontmatter: { + title: "Effect Tutorials", + }, +} + +const { tutorial } = Astro.props +const { Content } = await Astro.props.render() +--- + + +
{tutorial.title}
+ +
diff --git a/content/src/content/config.ts b/content/src/content/config.ts index 1d292cc98..d3052d080 100644 --- a/content/src/content/config.ts +++ b/content/src/content/config.ts @@ -3,25 +3,29 @@ import { defineCollection, z } from "astro:content" import { docsSchema } from "@astrojs/starlight/schema" import { blogSchema } from "starlight-blog/schema" import { podcastSchema } from "@/lib/schema/podcast" +import { tutorialSchema } from "@/lib/schema/tutorial" export const collections = { docs: defineCollection({ schema: docsSchema({ - extend: (context) => z.union([podcastSchema, blogSchema(context)]) + extend: (context) => + z.union([podcastSchema, tutorialSchema, blogSchema(context)]) }) }), transcripts: defineCollection({ loader: glob({ - pattern: "**\/*.json", + pattern: "**/*.json", base: "./src/data/transcripts" }), - schema: z.array(z.object({ - id: z.string(), - startTime: z.string(), - startSeconds: z.number(), - endTime: z.string(), - endSeconds: z.number(), - text: z.string() - })) + schema: z.array( + z.object({ + id: z.string(), + startTime: z.string(), + startSeconds: z.number(), + endTime: z.string(), + endSeconds: z.number(), + text: z.string() + }) + ) }) -}; +} diff --git a/content/src/content/docs/index.mdx b/content/src/content/docs/index.mdx index 2aa50a30c..8ece4769f 100644 --- a/content/src/content/docs/index.mdx +++ b/content/src/content/docs/index.mdx @@ -11,4 +11,10 @@ hero: - text: Blog link: /blog/ icon: right-arrow + - text: Learn + link: /learn/tutorials/introduction/ + icon: right-arrow + - text: Podcast + link: /podcast/ + icon: right-arrow --- diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx new file mode 100644 index 000000000..888b1e2c5 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -0,0 +1,261 @@ +--- +type: Tutorial +title: Introduction +tags: + - some + - tags +--- + +In this tutorial we're going to migrate an Express.js app to Effect. We'll learn +how to configure your existing Express.js codebase to allow migrating endpoints +one at a time, adopting Effect incrementally. We'll start with the base set of +Effect concepts you'll need to migrate the simplest endpoint possible, and we'll +introduce new concepts as we migrate more complex endpoints. + +## Our Express.js app + +We'll start with a simple Express.js app that has a single health checking +endpoint. + +```ts twoslash +import express from "express" + +const app = express() + +app.get("/health", (req, res) => { + res.type("text/plain").send("ok") +}) + +app.listen(3000, () => { + console.log("Server is running on http://localhost:3000") +}) +``` + +Create a new TypeScript project using your favourite package manager. I'm using +`bun`: + +```shell +mkdir express-to-effect +cd express-to-effect +bun init +``` + +Save our Express.js app into `index.ts` and run it to make sure it works: + +```shell +bun add express +bun run index.ts +``` + +You should see `Server is running on http://localhost:3000` in your terminal, +and visiting `http://localhost:3000/health` should return `ok`. + +## Migrating to Effect + +The first step to migrating this to Effect is to launch the Express.js server +with Effect instead of the traditional `app.listen`. Because the Express.js +`app` is a function that returns a node `http.Server` under the hood, it slots +nicely into Effect's `HttpServer` abstraction. + +```typescript +import express from "express" + +// New imports +import { HttpRouter, HttpServer } from "@effect/platform" +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { Layer } from "effect" +import { createServer } from "node:http" + +const app = express() + +app.get("/health", (req, res) => { + res.type("text/plain").send("ok") +}) + +// New server runner +NodeRuntime.runMain( + Layer.launch( + Layer.provide( + HttpServer.serve(HttpRouter.empty), + NodeHttpServer.layer(() => createServer(app), { port: 3000 }), + ), + ), +) +``` + +And we can run the resulting code like so: + +```shell +bun add @effect/platform @effect/platform-node +bun run index.ts +``` + +We haven't changed anything about how the server behaves. It still exposes a +single endpoint, `http://localhost:3000/health`, and that still returns `ok`. +But we've wrapped the Express.js app in a way that will allow us to define new +endpoints using Express while still responding to our existing endpoints in +Express.js. + +There's a lot of new things happening at the bottom of our file. For the time +being we're going to ignore most of it and focus on migrating an endpoint to +Effect. I promise by the time we're done, you'll understand every line we added +above. + +First, let's break out our router, `HttpRouter.empty`, out into its own variable +so it's easier to add to. + +```typescript +const router = HttpRouter.empty +``` + +And let's define the function we want to run when we hit the `/health` endpoint: + +```typescript +function health() { + return HttpServerResponse.text("ok") +} +``` + +`HttpServerResponse` is Effect's class for generating HTTP responses, we need to +use it for any responses returned from our endpoints defined with Effect. + +The way we wire these things together is going to look a bit strange, but bear +with me. + +```typescript +function health() { + return HttpServerResponse.text("ok") +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), +) +``` + +What on earth are `Effect.sync` and `.pipe`? + +## The Effect Type + +At the core of Effect is... well, the `Effect`. An `Effect` is what's called a +"thunk", which you can think of as a lazy computation. It doesn't do anything +until you run it. + +Here are some examples of simple `Effect`s: + +```typescript +const sync = Effect.sync(() => "Hello, world!") +const promise = Effect.promise(async () => "Hello, world!") +const succeed = Effect.succeed("Hello, world!") +``` + +Above we have examples of synchronous, asynchronous, and static Effects. None of +them will do anything as defined there, they need to be run. There are several +ways to run Effects. + +```typescript +const resultSync = Effect.runSync(sync) +const resultPromise = await Effect.runPromise(promise); +const resultSucceed = Effect.runSync(succeed) + +console.log(resultSync) +// "Hello, world!" +console.log(resultPromise) +// "Hello, world!" +console.log(resultSucceed) +// "Hello, world!" +``` + +This prints out `"Hello, world!"` three times. Note that you have to run +synchronous and asynchronous Effects differently, `runSync` and `runPromise` +respectively. + +What if you want to run an Effect that calls another Effect? You can use +`Effect.gen` to do that: + +```typescript +const gen = Effect.gen(function* () { + const result = yield* promise + return result.toUpperCase() +}) + +const resultGen = await Effect.runPromise(gen) +console.log(resultGen) +// "HELLO, WORLD!" +``` + +Given that all we really want to do above is call `.toUpperCase()` on the result +of `promise`, the scaffolding of `Effect.gen` feels a bit heavy. Effect gives us +plenty of tools to work with `Effect`s, one of which is `pipe`: + +```typescript +const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) +const result = await Effect.runPromise(upper) +console.log(result) +// "HELLO, WORLD!" +``` + +`pipe` passes the result of one computation as input to another. Here, +`Effect.map` transforms the result of the first `Effect` with the function +provided. + +We can `pipe` as many `Effect`s together as we like: + +```typescript +const upper = promise.pipe( + Effect.map((s) => s.toUpperCase()), + Effect.map((s) => s.split("")), + Effect.map((s) => s.reverse()), + Effect.map((s) => s.join("")), +) +const result = await Effect.runPromise(upper) +console.log(result) +// "!DLROW ,OLLEH" +``` + +## Understanding `HttpRouter` + +Looking back at `HttpRouter`, we used `pipe` to add a new endpoint to our app: + +```typescript +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), +); +``` + +`HttpRouter` is a data structure that represents a collection of routes. The +simplest router is one with no routes at all, and Effect exposes that to us as +the `HttpRouter.empty` value. Under the hood, this is itself an `Effect`: + +```typescript +console.log(Effect.isEffect(HttpRouter.empty)) +// true +``` + +The helper `HttpRouter.get` takes as input an `HttpRouter` and returns a new +`HttpRouter` with the given route added. If we wanted to, we could have done +this much more directly: + +```typescript +const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) +``` + +This is exactly the same as the `pipe` version, except that if we wanted to add +multiple routes it gets unwieldy quickly: + +```typescript +HttpRouter.get("/health", Effect.sync(health))( + HttpRouter.get("/status", Effect.sync(status))( + HttpRouter.get("/version", Effect.sync(version))( + HttpRouter.empty, + ), + ), +) + +// vs + +HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), + HttpRouter.get("/status", Effect.sync(status)), + HttpRouter.get("/version", Effect.sync(version)), +) +``` diff --git a/content/src/content/docs/podcast/episode-1.mdx b/content/src/content/docs/podcast/episode-1.mdx index 51cff2d16..6e96afc4a 100644 --- a/content/src/content/docs/podcast/episode-1.mdx +++ b/content/src/content/docs/podcast/episode-1.mdx @@ -1,4 +1,5 @@ --- +type: Podcast episode: 1 title: Adopting Effect at Zendesk with Attila Večerek transcript: podcast/episode-1 diff --git a/content/src/lib/content/tutorial.ts b/content/src/lib/content/tutorial.ts new file mode 100644 index 000000000..b874eb133 --- /dev/null +++ b/content/src/lib/content/tutorial.ts @@ -0,0 +1,26 @@ +import { getCollection, type CollectionEntry } from "astro:content" +import type { Tutorial } from "../schema/tutorial" + +export interface TutorialCollectionEntry { + body: string + collection: string + data: Tutorial & CollectionEntry<"docs">["data"] + id: string + render: () => Promise<{ + Content: import("astro").MarkdownInstance["Content"] + }> + slug: string +} + +export async function getTutorialEntries() { + const docEntries = await getCollection("docs") + const tutorialEntries: Array = [] + + for (const entry of docEntries) { + if (entry.id.startsWith("learn/tutorials")) { + tutorialEntries.push(entry as TutorialCollectionEntry) + } + } + + return tutorialEntries +} diff --git a/content/src/lib/schema/podcast.ts b/content/src/lib/schema/podcast.ts index c2b75fac3..fad5956cf 100644 --- a/content/src/lib/schema/podcast.ts +++ b/content/src/lib/schema/podcast.ts @@ -1,6 +1,10 @@ import { reference, z } from "astro:content" export const podcastSchema = z.object({ + /** + * Required at the moment to differentiate a podcast schema from others. + */ + type: z.literal("Podcast"), /** * A non-zero integer representing the episode number. */ diff --git a/content/src/lib/schema/tutorial.ts b/content/src/lib/schema/tutorial.ts new file mode 100644 index 000000000..4fd2d17fb --- /dev/null +++ b/content/src/lib/schema/tutorial.ts @@ -0,0 +1,14 @@ +import { z } from "astro:content" + +export const tutorialSchema = z.object({ + /** + * Required at the moment to differentiate a tutorial schema from others. + */ + type: z.literal("Tutorial"), + /** + * The list of tags associated with the tutorial. + */ + tags: z.array(z.string()) +}) + +export type Tutorial = z.TypeOf diff --git a/content/src/plugins/starlight/learn.ts b/content/src/plugins/starlight/learn.ts new file mode 100644 index 000000000..c3ada213b --- /dev/null +++ b/content/src/plugins/starlight/learn.ts @@ -0,0 +1,24 @@ +import type { StarlightPlugin } from "@astrojs/starlight/types" + +export function effectLearnPlugin(): StarlightPlugin { + return { + name: "starlight-effect-learn-plugin", + hooks: { + setup({ addIntegration }) { + addIntegration({ + name: "effect-learn-integration", + hooks: { + "astro:config:setup": ({ injectRoute }) => { + injectRoute({ + entrypoint: + "./src/components/plugins/learn/Tutorial.astro", + pattern: "/[...prefix]/tutorials/[...tutorial]", + prerender: true + }) + } + } + }) + } + } + } +} diff --git a/content/src/plugins/starlight/podcast.ts b/content/src/plugins/starlight/podcast.ts index aacb9dca4..b6fc652e3 100644 --- a/content/src/plugins/starlight/podcast.ts +++ b/content/src/plugins/starlight/podcast.ts @@ -6,7 +6,7 @@ export function effectPodcastPlugin(): StarlightPlugin { hooks: { setup({ addIntegration, astroConfig }) { addIntegration({ - name: "effect-playground-integration", + name: "effect-podcast-integration", hooks: { "astro:config:setup": ({ injectRoute }) => { injectRoute({