An educational pure functional programming library in TypeScript
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Remove old flat example files

+1 -4397
-551
examples/demo.ts
··· 1 - /** 2 - * purus — Full Demo 3 - * 4 - * Showcasing all features of the unified library: 5 - * • Part I: Foundations (types, refinements, pattern matching) 6 - * • Part II: Effect System (fibers, concurrency, cancellation) 7 - */ 8 - 9 - import { 10 - // === FOUNDATIONS === 11 - // Brands & Refinements 12 - Branded, 13 - brand, 14 - Refined, 15 - refine, 16 - positive, 17 - integer, 18 - 19 - // Result & Option 20 - Result, 21 - Option, 22 - ok, 23 - err, 24 - some, 25 - none, 26 - mapResult, 27 - chainResult, 28 - unwrapOr, 29 - mapOption, 30 - getOrElse, 31 - flatMapOption, 32 - fromNullable, 33 - 34 - // Pattern Matching 35 - match, 36 - matchResult, 37 - matchOption, 38 - when, 39 - matchLiteral, 40 - 41 - // Arrays 42 - Arr, 43 - arr, 44 - sortNum, 45 - sortBy, 46 - nonEmpty, 47 - map, 48 - filter, 49 - reduce, 50 - head, 51 - last, 52 - binarySearch, 53 - 54 - // Units 55 - Quantity, 56 - meters, 57 - seconds, 58 - velocity, 59 - addQ, 60 - 61 - // State 62 - Entity, 63 - entity, 64 - transition, 65 - 66 - // Composition & Utils 67 - pipe, 68 - flow, 69 - tap, 70 - trace, 71 - ifElse, 72 - 73 - // === EFFECT SYSTEM === 74 - // Core types 75 - Eff, 76 - Fiber, 77 - Exit, 78 - 79 - // Constructors 80 - succeed, 81 - fail, 82 - sync, 83 - async, 84 - fromPromise, 85 - 86 - // Transformations 87 - mapEff, 88 - flatMap, 89 - foldEff, 90 - catchAll, 91 - access, 92 - accessEff, 93 - provide, 94 - 95 - // Concurrency 96 - fork, 97 - join, 98 - race, 99 - all, 100 - 101 - // Combinators 102 - sleep, 103 - timeout, 104 - retry, 105 - repeatEff, 106 - tapEff, 107 - 108 - // Control 109 - yieldNow, 110 - fiberId, 111 - 112 - // Runners 113 - runPromise, 114 - runPromiseExit, 115 - runFiber, 116 - } from "../src/index"; 117 - 118 - // ============================================================================= 119 - // PART I: FOUNDATIONS DEMO 120 - // ============================================================================= 121 - 122 - const foundationsDemo = () => { 123 - console.log( 124 - "╔═══════════════════════════════════════════════════════════════╗", 125 - ); 126 - console.log( 127 - "║ PART I: FOUNDATIONS ║", 128 - ); 129 - console.log( 130 - "╚═══════════════════════════════════════════════════════════════╝", 131 - ); 132 - 133 - // ─── Branded Types ─── 134 - console.log("\n─── Branded Types ───"); 135 - type UserId = Branded<string, "UserId">; 136 - type Email = Branded<string, "Email">; 137 - 138 - const userId: UserId = brand("user-123"); 139 - const email: Email = brand("alice@example.com"); 140 - console.log(`UserId: ${userId}, Email: ${email}`); 141 - // Can't mix them up: userId = email would be a type error! 142 - // 143 - 144 - // ─── Refined Types ─── 145 - console.log("\n─── Refined Types ───"); 146 - const age = positive(25); 147 - const invalid = positive(-5); 148 - console.log(`positive(25) = ${age}`); 149 - console.log(`positive(-5) = ${invalid}`); 150 - 151 - // ─── Result Type ─── 152 - console.log("\n─── Result Type ───"); 153 - const divide = (a: number, b: number): Result<number, "DivByZero"> => 154 - ifElse<number, "DivByZero">(b === 0)( 155 - () => err("DivByZero"), 156 - () => ok(a / b), 157 - ); 158 - 159 - const result1 = pipe( 160 - divide(10, 2), 161 - chainResult((x) => divide(x, 2)), 162 - matchResult( 163 - (v) => `Success: ${v}`, 164 - (e) => `Error: ${e}`, 165 - ), 166 - ); 167 - console.log(`10 / 2 / 2 = ${result1}`); 168 - 169 - const result2 = pipe( 170 - divide(10, 0), 171 - matchResult( 172 - (v) => `Success: ${v}`, 173 - (e) => `Error: ${e}`, 174 - ), 175 - ); 176 - console.log(`10 / 0 = ${result2}`); 177 - 178 - // ─── Option Type ─── 179 - console.log("\n─── Option Type ───"); 180 - const config = { host: "localhost", port: null as number | null }; 181 - const port = pipe(fromNullable(config.port), getOrElse(3000)); 182 - console.log(`Port (with default): ${port}`); 183 - 184 - // ─── Pattern Matching ─── 185 - console.log("\n─── Pattern Matching ───"); 186 - const grade = (score: number) => 187 - when(score)( 188 - [(s) => s >= 90, () => "A"], 189 - [(s) => s >= 80, () => "B"], 190 - [(s) => s >= 70, () => "C"], 191 - )(() => "F"); 192 - 193 - console.log(`Score 85 = Grade ${grade(85)}`); 194 - console.log(`Score 65 = Grade ${grade(65)}`); 195 - 196 - // ─── Tracked Arrays ─── 197 - console.log("\n─── Tracked Arrays ───"); 198 - type User = { name: string; score: number }; 199 - const users: User[] = [ 200 - { name: "Charlie", score: 75 }, 201 - { name: "Alice", score: 92 }, 202 - { name: "Bob", score: 88 }, 203 - ]; 204 - 205 - const topScorer = pipe( 206 - arr(users), 207 - sortBy((u) => -u.score), // Arr<User, Sorted> 208 - nonEmpty, // Option<Arr<User, Sorted | NonEmpty>> 209 - matchOption( 210 - (xs) => `Top: ${head(xs).name} (${head(xs).score})`, 211 - () => "No users", 212 - ), 213 - ); 214 - console.log(topScorer); 215 - 216 - // ─── Units ─── 217 - console.log("\n─── Units ───"); 218 - const distance = meters(100); 219 - const time = seconds(9.58); 220 - const speed = velocity(distance, time); 221 - console.log(`${distance}m in ${time}s = ${speed.toFixed(2)} m/s`); 222 - 223 - // addQ(distance, time) would be a type error! 224 - const totalDistance = addQ(distance, meters(200)); 225 - console.log(`Total distance: ${totalDistance}m`); 226 - 227 - // ─── State Machines ─── 228 - console.log("\n─── State Machines ───"); 229 - type Doc = { title: string; content: string }; 230 - type Draft = Entity<Doc, "draft">; 231 - type Published = Entity<Doc, "published">; 232 - 233 - const createDraft = (title: string): Draft => 234 - entity<Doc, "draft">({ title, content: "" }); 235 - 236 - const edit = (content: string) => 237 - transition<Doc, "draft", "draft">((d) => ({ ...d, content })); 238 - 239 - const publish: (d: Draft) => Published = transition< 240 - Doc, 241 - "draft", 242 - "published" 243 - >(); 244 - 245 - const doc = pipe(createDraft("My Post"), edit("Hello, world!"), publish); 246 - console.log(`Published: "${doc.title}" - "${doc.content}"`); 247 - }; 248 - 249 - // ============================================================================= 250 - // PART II: EFFECT SYSTEM DEMO 251 - // ============================================================================= 252 - 253 - const effectDemo = async () => { 254 - console.log( 255 - "\n╔═══════════════════════════════════════════════════════════════╗", 256 - ); 257 - console.log( 258 - "║ PART II: EFFECT SYSTEM ║", 259 - ); 260 - console.log( 261 - "╚═══════════════════════════════════════════════════════════════╝", 262 - ); 263 - 264 - // ─── Basic Effects ─── 265 - console.log("\n─── Basic Effects ───"); 266 - 267 - const greeting = pipe( 268 - succeed("Hello"), 269 - flatMap((s) => succeed(`${s}, World!`)), 270 - ); 271 - console.log(await runPromise(greeting)); 272 - 273 - // ─── Error Handling ─── 274 - console.log("\n─── Error Handling ───"); 275 - 276 - type ApiError = { _tag: "ApiError"; code: number }; 277 - 278 - const riskyCall = fail<ApiError>({ _tag: "ApiError", code: 404 }); 279 - 280 - const handled = pipe( 281 - riskyCall, 282 - catchAll((e) => succeed(`Caught error: ${e.code}`)), 283 - ); 284 - console.log(await runPromise(handled)); 285 - 286 - // ─── Async Effects ─── 287 - console.log("\n─── Async Effects ───"); 288 - 289 - const delayed = pipe( 290 - sync(() => console.log(" Starting...")), 291 - flatMap(() => sleep(100)), 292 - flatMap(() => succeed("Done after 100ms")), 293 - ); 294 - console.log(await runPromise(delayed)); 295 - 296 - // ─── Timeout ─── 297 - console.log("\n─── Timeout ───"); 298 - 299 - const slow = pipe( 300 - sleep(500), 301 - mapEff(() => "completed"), 302 - ); 303 - const fast = pipe( 304 - sleep(50), 305 - mapEff(() => "completed"), 306 - ); 307 - 308 - console.log("Slow with 100ms timeout:", await runPromise(timeout(100)(slow))); 309 - console.log("Fast with 100ms timeout:", await runPromise(timeout(100)(fast))); 310 - 311 - // ─── Parallel Execution ─── 312 - console.log("\n─── Parallel Execution ───"); 313 - 314 - const task = (name: string, ms: number) => 315 - pipe( 316 - sync(() => console.log(` ${name} starting`)), 317 - flatMap(() => sleep(ms)), 318 - flatMap(() => 319 - sync(() => { 320 - console.log(` ${name} done`); 321 - return name; 322 - }), 323 - ), 324 - ); 325 - 326 - const start = Date.now(); 327 - const results = await runPromise( 328 - all([task("A", 100), task("B", 150), task("C", 50)]), 329 - ); 330 - console.log(`Results: [${results.join(", ")}] in ${Date.now() - start}ms`); 331 - 332 - // ─── Racing ─── 333 - console.log("\n─── Racing ───"); 334 - 335 - const winner = await runPromise( 336 - race( 337 - pipe( 338 - sleep(100), 339 - mapEff(() => "Slow"), 340 - ), 341 - pipe( 342 - sleep(30), 343 - mapEff(() => "Fast"), 344 - ), 345 - ), 346 - ); 347 - console.log(`Winner: ${winner}`); 348 - 349 - // ─── Cancellation ─── 350 - console.log("\n─── Cancellation ───"); 351 - 352 - const longTask = pipe( 353 - fiberId, 354 - flatMap((id) => { 355 - const loop = (n: number): Eff<string, never, unknown> => 356 - n <= 0 357 - ? succeed("completed") 358 - : pipe( 359 - sync(() => console.log(` Fiber ${id.id}: step ${5 - n + 1}`)), 360 - flatMap(() => sleep(50)), 361 - flatMap(() => yieldNow), 362 - flatMap(() => loop(n - 1)), 363 - ); 364 - return loop(5); 365 - }), 366 - ); 367 - 368 - const fiber = runFiber(longTask, {}); 369 - setTimeout(() => fiber.interrupt(), 120); 370 - 371 - const exit = await fiber.await(); 372 - console.log(`Exit: ${exit._tag}`); 373 - 374 - // ─── Dependency Injection ─── 375 - console.log("\n─── Dependency Injection ───"); 376 - 377 - type Logger = { log: (msg: string) => void }; 378 - type Env = { logger: Logger }; 379 - 380 - const program = pipe( 381 - accessEff<Env, void, never>(({ logger }) => 382 - sync(() => logger.log("Hello from injected logger!")), 383 - ), 384 - flatMap(() => succeed("Done")), 385 - ); 386 - 387 - const result = await runPromise( 388 - provide({ logger: { log: (m: string) => console.log(` [LOG] ${m}`) } })( 389 - program, 390 - ), 391 - ); 392 - console.log(`Result: ${result}`); 393 - 394 - // ─── Retry ─── 395 - console.log("\n─── Retry ───"); 396 - 397 - let attempts = 0; 398 - const flaky = pipe( 399 - sync(() => { 400 - attempts++; 401 - console.log(` Attempt ${attempts}`); 402 - if (attempts < 3) throw new Error("Flaky!"); 403 - return "success"; 404 - }), 405 - foldEff( 406 - (e) => fail(e), 407 - (a) => succeed(a), 408 - ), 409 - ); 410 - 411 - const retried = await runPromise(retry(5)(flaky)); 412 - console.log(`After retries: ${retried}`); 413 - }; 414 - 415 - // ============================================================================= 416 - // COMBINED EXAMPLE: Real-world workflow 417 - // ============================================================================= 418 - 419 - const realWorldDemo = async () => { 420 - console.log( 421 - "\n╔═══════════════════════════════════════════════════════════════╗", 422 - ); 423 - console.log( 424 - "║ REAL-WORLD EXAMPLE ║", 425 - ); 426 - console.log( 427 - "╚═══════════════════════════════════════════════════════════════╝", 428 - ); 429 - 430 - // Branded IDs 431 - type UserId = Branded<string, "UserId">; 432 - type OrderId = Branded<string, "OrderId">; 433 - 434 - // Error types 435 - type UserNotFound = { _tag: "UserNotFound"; userId: UserId }; 436 - type InvalidOrder = { _tag: "InvalidOrder"; reason: string }; 437 - type AppError = UserNotFound | InvalidOrder; 438 - 439 - // Services 440 - type UserService = { 441 - getUser: (id: UserId) => Eff<{ name: string }, UserNotFound, unknown>; 442 - }; 443 - 444 - type OrderService = { 445 - createOrder: ( 446 - userId: UserId, 447 - items: string[], 448 - ) => Eff<OrderId, InvalidOrder, unknown>; 449 - }; 450 - 451 - type Services = { users: UserService; orders: OrderService }; 452 - 453 - // Implementation 454 - const mockServices: Services = { 455 - users: { 456 - getUser: (id) => 457 - pipe( 458 - sleep(50), 459 - flatMap(() => 460 - id === brand("user-1") 461 - ? succeed({ name: "Alice" }) 462 - : fail<UserNotFound>({ _tag: "UserNotFound", userId: id }), 463 - ), 464 - ), 465 - }, 466 - orders: { 467 - createOrder: (userId, items) => 468 - pipe( 469 - sleep(50), 470 - flatMap(() => 471 - items.length > 0 472 - ? succeed(brand<string, "OrderId">(`order-${Date.now()}`)) 473 - : fail<InvalidOrder>({ 474 - _tag: "InvalidOrder", 475 - reason: "Empty cart", 476 - }), 477 - ), 478 - ), 479 - }, 480 - }; 481 - 482 - // Business logic 483 - const placeOrder = (userId: UserId, items: string[]) => 484 - accessEff<Services, OrderId, AppError>(({ users, orders }) => 485 - pipe( 486 - users.getUser(userId), 487 - flatMap((user) => 488 - pipe( 489 - sync(() => console.log(` Placing order for ${user.name}...`)), 490 - flatMap(() => orders.createOrder(userId, items)), 491 - ), 492 - ), 493 - ), 494 - ); 495 - 496 - // Run it 497 - console.log("\n─── Successful Order ───"); 498 - const order1 = await runPromise( 499 - pipe( 500 - placeOrder(brand("user-1"), ["Widget", "Gadget"]), 501 - provide(mockServices), 502 - timeout(1000), 503 - catchAll((e) => succeed(`Error: ${e._tag}` as any)), 504 - ), 505 - ); 506 - console.log(` Order: ${order1}`); 507 - 508 - console.log("\n─── User Not Found ───"); 509 - const order2 = await runPromise( 510 - pipe( 511 - placeOrder(brand("user-999"), ["Item"]), 512 - provide(mockServices), 513 - catchAll((e) => succeed(`Error: ${e._tag}`)), 514 - ), 515 - ); 516 - console.log(` Result: ${order2}`); 517 - 518 - console.log("\n─── Invalid Order ───"); 519 - const order3 = await runPromise( 520 - pipe( 521 - placeOrder(brand("user-1"), []), 522 - provide(mockServices), 523 - catchAll((e) => succeed(`Error: ${(e as InvalidOrder).reason}`)), 524 - ), 525 - ); 526 - console.log(` Result: ${order3}`); 527 - }; 528 - 529 - // ============================================================================= 530 - // RUN ALL DEMOS 531 - // ============================================================================= 532 - 533 - const main = async () => { 534 - foundationsDemo(); 535 - await effectDemo(); 536 - await realWorldDemo(); 537 - 538 - console.log( 539 - "\n╔═══════════════════════════════════════════════════════════════╗", 540 - ); 541 - console.log( 542 - "║ ✨ ALL DEMOS COMPLETE ✨ ║", 543 - ); 544 - console.log( 545 - "╚═══════════════════════════════════════════════════════════════╝", 546 - ); 547 - }; 548 - 549 - main() 550 - .catch(console.error) 551 - .finally(() => process.exit(0));
-680
examples/game-loop.ts
··· 1 - /** 2 - * Game Loop with Typed State and Physical Units 3 - * 4 - * Showcases: Typestate, units, tracked arrays, effect scheduling, pattern matching 5 - * 6 - * ## Why purus is better than vanilla JS/TS: 7 - * 8 - * 1. **Can't attack from Dead state (compile error)** 9 - * - Vanilla: `if (entity.state !== 'dead') entity.attack()` - runtime check 10 - * - Purus: `attack(entity: Entity<Alive>)` - can't call with dead entity 11 - * 12 - * 2. **Can't mix position with velocity (unit types)** 13 - * - Vanilla: `x += velocity` might accidentally add meters to meters/second 14 - * - Purus: `Position<Pixels>` + `Velocity<PixelsPerSecond>` - type error on mismatch 15 - * 16 - * 3. **Render list is guaranteed sorted at compile time** 17 - * - Vanilla: Hope you remembered to sort by z-index before rendering 18 - * - Purus: `Arr<Entity, Sorted>` - binarySearch only works on sorted arrays 19 - * 20 - * 4. **Game loop is just effect composition** 21 - * - Vanilla: `requestAnimationFrame` callback pyramid 22 - * - Purus: `pipe(update, flatMap(render), repeatEff(frames))` 23 - * 24 - * 5. **Entity state transitions are type-checked** 25 - * - Vanilla: String literals, easy to typo, no compiler help 26 - * - Purus: Phantom types enforce valid transitions 27 - * 28 - * 6. **Frame timing with sleep effect** 29 - * - Vanilla: Manual delta time calculation, setTimeout 30 - * - Purus: `sleep(16)` yields to scheduler, maintains frame rate 31 - */ 32 - 33 - import { 34 - // Types 35 - type Eff, 36 - type Exit, 37 - type Fiber, 38 - type Entity, 39 - type Quantity, 40 - type Arr, 41 - type Branded, 42 - type Refined, 43 - type Option, 44 - 45 - // Constructors 46 - succeed, 47 - fail, 48 - async, 49 - sync, 50 - entity, 51 - transition, 52 - quantity, 53 - arr, 54 - some, 55 - none, 56 - 57 - // Refinements 58 - positive, 59 - nonEmpty, 60 - 61 - // Array operations 62 - sortBy, 63 - filter, 64 - map, 65 - head, 66 - 67 - // Transformations 68 - mapEff, 69 - flatMap, 70 - foldEff, 71 - catchAll, 72 - 73 - // Concurrency 74 - fork, 75 - join, 76 - race, 77 - interruptFiber, 78 - 79 - // Combinators 80 - sleep, 81 - timeout, 82 - repeatEff, 83 - tapEff, 84 - 85 - // Pattern matching 86 - match, 87 - matchOption, 88 - 89 - // Composition 90 - pipe, 91 - brand, 92 - 93 - // Runners 94 - runFiber, 95 - runPromise, 96 - runPromiseExit, 97 - } from "../src/index" 98 - 99 - // ============================================================================= 100 - // PHYSICAL UNITS - Compile-time dimensional analysis 101 - // ============================================================================= 102 - 103 - /** 104 - * Physical unit types 105 - * 106 - * Vanilla problem: 107 - * ```ts 108 - * let x = 100 // pixels? meters? who knows! 109 - * let vx = 5 // pixels/frame? meters/second? 110 - * x += vx // Is this even valid? 111 - * ``` 112 - * 113 - * Purus solution: 114 - * ```ts 115 - * let x: Pixels = pixels(100) 116 - * let vx: PixelsPerSecond = pps(5) 117 - * x = addPixels(x, scaleByTime(vx, dt)) // Type-checked! 118 - * ``` 119 - */ 120 - 121 - // Unit type aliases 122 - type Pixels = "px" 123 - type Seconds = "s" 124 - type PixelsPerSecond = "px/s" 125 - type Degrees = "deg" 126 - type DegreesPerSecond = "deg/s" 127 - 128 - // Unit constructors 129 - const pixels = (n: number): Quantity<number, Pixels> => quantity<Pixels>(n) 130 - const seconds = (n: number): Quantity<number, Seconds> => quantity<Seconds>(n) 131 - const pps = (n: number): Quantity<number, PixelsPerSecond> => quantity<PixelsPerSecond>(n) 132 - const degrees = (n: number): Quantity<number, Degrees> => quantity<Degrees>(n) 133 - const dps = (n: number): Quantity<number, DegreesPerSecond> => quantity<DegreesPerSecond>(n) 134 - 135 - // Unit arithmetic 136 - const addPixels = (a: Quantity<number, Pixels>, b: Quantity<number, Pixels>): Quantity<number, Pixels> => 137 - quantity<Pixels>((a as number) + (b as number)) 138 - 139 - const subPixels = (a: Quantity<number, Pixels>, b: Quantity<number, Pixels>): Quantity<number, Pixels> => 140 - quantity<Pixels>((a as number) - (b as number)) 141 - 142 - const scaleVelocity = (v: Quantity<number, PixelsPerSecond>, t: Quantity<number, Seconds>): Quantity<number, Pixels> => 143 - quantity<Pixels>((v as number) * (t as number)) 144 - 145 - const addDegrees = (a: Quantity<number, Degrees>, b: Quantity<number, Degrees>): Quantity<number, Degrees> => 146 - quantity<Degrees>(((a as number) + (b as number)) % 360) 147 - 148 - const scaleRotation = (r: Quantity<number, DegreesPerSecond>, t: Quantity<number, Seconds>): Quantity<number, Degrees> => 149 - quantity<Degrees>((r as number) * (t as number)) 150 - 151 - // ============================================================================= 152 - // ENTITY ID 153 - // ============================================================================= 154 - 155 - type EntityId = Branded<string, "EntityId"> 156 - const EntityId = (): EntityId => brand(`entity-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) 157 - 158 - // ============================================================================= 159 - // GAME ENTITY TYPESTATE - State machine for entity lifecycle 160 - // ============================================================================= 161 - 162 - /** 163 - * Entity states as phantom types 164 - * 165 - * Vanilla problem: 166 - * ```ts 167 - * type Entity = { 168 - * state: "idle" | "moving" | "attacking" | "dead" 169 - * } 170 - * 171 - * function attack(entity: Entity) { 172 - * if (entity.state === "dead") throw new Error("Can't attack when dead") 173 - * // ... 174 - * } 175 - * ``` 176 - * 177 - * Purus solution: 178 - * ```ts 179 - * type DeadEntity = Entity<EntityData, "Dead"> 180 - * 181 - * function attack(entity: Entity<EntityData, "Idle" | "Moving">): Entity<EntityData, "Attacking"> { 182 - * // Can ONLY be called with non-dead entities 183 - * // Compiler enforces this! 184 - * } 185 - * 186 - * // This is a compile error: 187 - * attack(deadEntity) // Type 'DeadEntity' is not assignable to 'IdleEntity | MovingEntity' 188 - * ``` 189 - */ 190 - 191 - type Idle = "Idle" 192 - type Moving = "Moving" 193 - type Attacking = "Attacking" 194 - type Stunned = "Stunned" 195 - type Dead = "Dead" 196 - 197 - type AliveState = Idle | Moving | Attacking | Stunned 198 - type GameEntityState = AliveState | Dead 199 - 200 - type EntityData = { 201 - readonly id: EntityId 202 - readonly name: string 203 - readonly x: Quantity<number, Pixels> 204 - readonly y: Quantity<number, Pixels> 205 - readonly vx: Quantity<number, PixelsPerSecond> 206 - readonly vy: Quantity<number, PixelsPerSecond> 207 - readonly rotation: Quantity<number, Degrees> 208 - readonly rotationSpeed: Quantity<number, DegreesPerSecond> 209 - readonly health: number 210 - readonly maxHealth: number 211 - readonly zIndex: number 212 - } 213 - 214 - type IdleEntity = Entity<EntityData, Idle> 215 - type MovingEntity = Entity<EntityData, Moving> 216 - type AttackingEntity = Entity<EntityData, Attacking> 217 - type StunnedEntity = Entity<EntityData, Stunned> 218 - type DeadEntity = Entity<EntityData, Dead> 219 - 220 - type GameEntity = IdleEntity | MovingEntity | AttackingEntity | StunnedEntity | DeadEntity 221 - 222 - // State transition functions 223 - const toIdle = <S extends AliveState>() => transition<EntityData, S, Idle>() 224 - const toMoving = <S extends AliveState>() => transition<EntityData, S, Moving>() 225 - const toAttacking = <S extends Idle | Moving>() => transition<EntityData, S, Attacking>() 226 - const toStunned = <S extends AliveState>() => transition<EntityData, S, Stunned>() 227 - const toDead = <S extends AliveState>() => transition<EntityData, S, Dead>() 228 - 229 - // ============================================================================= 230 - // ENTITY FACTORIES 231 - // ============================================================================= 232 - 233 - const createEntity = ( 234 - name: string, 235 - x: number, 236 - y: number, 237 - zIndex: number = 0 238 - ): IdleEntity => 239 - entity<EntityData, Idle>({ 240 - id: EntityId(), 241 - name, 242 - x: pixels(x), 243 - y: pixels(y), 244 - vx: pps(0), 245 - vy: pps(0), 246 - rotation: degrees(0), 247 - rotationSpeed: dps(0), 248 - health: 100, 249 - maxHealth: 100, 250 - zIndex, 251 - }) 252 - 253 - // ============================================================================= 254 - // GAME ACTIONS - Type-safe state transitions 255 - // ============================================================================= 256 - 257 - /** 258 - * Start moving an entity 259 - * 260 - * Note: Can only move Idle or Stunned entities 261 - * Dead entities can't move - enforced at compile time! 262 - */ 263 - const startMoving = ( 264 - entity: IdleEntity | StunnedEntity, 265 - vx: Quantity<number, PixelsPerSecond>, 266 - vy: Quantity<number, PixelsPerSecond> 267 - ): MovingEntity => 268 - transition<EntityData, Idle | Stunned, Moving>(e => ({ 269 - ...e, 270 - vx, 271 - vy, 272 - }))(entity) 273 - 274 - /** 275 - * Stop moving an entity 276 - */ 277 - const stopMoving = (entity: MovingEntity): IdleEntity => 278 - transition<EntityData, Moving, Idle>(e => ({ 279 - ...e, 280 - vx: pps(0), 281 - vy: pps(0), 282 - }))(entity) 283 - 284 - /** 285 - * Attack - can only attack from Idle or Moving state 286 - * 287 - * Try calling this with a DeadEntity - you'll get a compile error! 288 - */ 289 - const attack = (entity: IdleEntity | MovingEntity): AttackingEntity => 290 - toAttacking<Idle | Moving>()(entity) 291 - 292 - /** 293 - * Finish attack animation 294 - */ 295 - const finishAttack = (entity: AttackingEntity): IdleEntity => 296 - toIdle<Attacking>()(entity) 297 - 298 - /** 299 - * Take damage - may transition to Dead 300 - */ 301 - const takeDamage = <S extends AliveState>( 302 - entity: Entity<EntityData, S>, 303 - damage: number 304 - ): Entity<EntityData, S> | DeadEntity => { 305 - const newHealth = entity.health - damage 306 - if (newHealth <= 0) { 307 - return toDead<S>()(entity) 308 - } 309 - return { ...entity, health: newHealth } as Entity<EntityData, S> 310 - } 311 - 312 - /** 313 - * Stun an entity 314 - */ 315 - const stun = <S extends AliveState>(entity: Entity<EntityData, S>): StunnedEntity => 316 - toStunned<S>()(entity) 317 - 318 - /** 319 - * Recover from stun 320 - */ 321 - const recoverFromStun = (entity: StunnedEntity): IdleEntity => 322 - toIdle<Stunned>()(entity) 323 - 324 - // ============================================================================= 325 - // PHYSICS UPDATE - Type-safe position/velocity calculations 326 - // ============================================================================= 327 - 328 - /** 329 - * Update entity position based on velocity and delta time 330 - * 331 - * This is where unit types shine: 332 - * - Can't accidentally add velocity to position without scaling by time 333 - * - All arithmetic is type-checked 334 - */ 335 - const updatePhysics = <S extends GameEntityState>( 336 - entity: Entity<EntityData, S>, 337 - deltaTime: Quantity<number, Seconds> 338 - ): Entity<EntityData, S> => { 339 - const dx = scaleVelocity(entity.vx, deltaTime) 340 - const dy = scaleVelocity(entity.vy, deltaTime) 341 - const dRotation = scaleRotation(entity.rotationSpeed, deltaTime) 342 - 343 - return { 344 - ...entity, 345 - x: addPixels(entity.x, dx), 346 - y: addPixels(entity.y, dy), 347 - rotation: addDegrees(entity.rotation, dRotation), 348 - } as Entity<EntityData, S> 349 - } 350 - 351 - // ============================================================================= 352 - // RENDER SORTING - Guaranteed sorted at compile time 353 - // ============================================================================= 354 - 355 - /** 356 - * Sort entities by z-index for rendering 357 - * 358 - * The return type `Arr<GameEntity, Sorted>` guarantees the array is sorted. 359 - * Functions that need a sorted array (like binarySearch) can require this type. 360 - */ 361 - const sortByZIndex = (entities: readonly GameEntity[]): Arr<GameEntity, "Sorted"> => 362 - sortBy<GameEntity, never>(e => e.zIndex)(arr(entities)) 363 - 364 - /** 365 - * Get the topmost entity (highest z-index) 366 - * 367 - * Note: This requires a NonEmpty array - can't get topmost from empty! 368 - */ 369 - const getTopmost = (entities: Arr<GameEntity, "Sorted" | "NonEmpty">): GameEntity => 370 - head(entities) 371 - 372 - // ============================================================================= 373 - // GAME LOOP - Effect-based frame scheduling 374 - // ============================================================================= 375 - 376 - type GameState = { 377 - readonly entities: readonly GameEntity[] 378 - readonly frameCount: number 379 - readonly lastUpdateTime: number 380 - } 381 - 382 - const createGameState = (entities: readonly GameEntity[]): GameState => ({ 383 - entities, 384 - frameCount: 0, 385 - lastUpdateTime: Date.now(), 386 - }) 387 - 388 - /** 389 - * Single game frame update 390 - */ 391 - const updateFrame = (state: GameState): Eff<GameState, never, unknown> => 392 - sync(() => { 393 - const now = Date.now() 394 - const deltaMs = now - state.lastUpdateTime 395 - const deltaTime = seconds(deltaMs / 1000) 396 - 397 - // Update all entity physics 398 - const updatedEntities = state.entities.map(entity => updatePhysics(entity, deltaTime)) 399 - 400 - return { 401 - entities: updatedEntities, 402 - frameCount: state.frameCount + 1, 403 - lastUpdateTime: now, 404 - } 405 - }) 406 - 407 - /** 408 - * Render a single frame 409 - */ 410 - const renderFrame = (state: GameState): Eff<void, never, unknown> => 411 - sync(() => { 412 - // Sort by z-index for proper rendering order 413 - const sorted = sortByZIndex(state.entities) 414 - 415 - // Log frame info (in real game, this would draw to canvas) 416 - if (state.frameCount % 60 === 0) { 417 - console.log(` Frame ${state.frameCount}:`) 418 - sorted.forEach(e => { 419 - const stateTag = getEntityState(e) 420 - console.log(` ${e.name} (${stateTag}): pos=(${Math.round(e.x as number)}, ${Math.round(e.y as number)}), health=${e.health}`) 421 - }) 422 - } 423 - }) 424 - 425 - /** 426 - * Get entity state tag (for debugging) 427 - */ 428 - const getEntityState = (entity: GameEntity): string => { 429 - if (entity.health <= 0) return "Dead" 430 - if ((entity.vx as number) !== 0 || (entity.vy as number) !== 0) return "Moving" 431 - return "Idle" 432 - } 433 - 434 - /** 435 - * Game loop - runs at ~60fps 436 - */ 437 - const gameLoop = (initialState: GameState, maxFrames: number): Eff<GameState, never, unknown> => { 438 - const loop = (state: GameState): Eff<GameState, never, unknown> => { 439 - if (state.frameCount >= maxFrames) { 440 - return succeed(state) 441 - } 442 - 443 - return pipe( 444 - updateFrame(state), 445 - flatMap(newState => 446 - pipe( 447 - renderFrame(newState), 448 - flatMap(() => sleep(16)), // ~60 FPS 449 - flatMap(() => loop(newState)) 450 - ) 451 - ) 452 - ) 453 - } 454 - 455 - return loop(initialState) 456 - } 457 - 458 - // ============================================================================= 459 - // INPUT HANDLING - Effect-based event processing 460 - // ============================================================================= 461 - 462 - type InputEvent = 463 - | { readonly _tag: "Move"; readonly entityId: EntityId; readonly vx: number; readonly vy: number } 464 - | { readonly _tag: "Attack"; readonly entityId: EntityId } 465 - | { readonly _tag: "Stop"; readonly entityId: EntityId } 466 - 467 - const InputEvent = { 468 - move: (entityId: EntityId, vx: number, vy: number): InputEvent => 469 - ({ _tag: "Move", entityId, vx, vy }), 470 - attack: (entityId: EntityId): InputEvent => 471 - ({ _tag: "Attack", entityId }), 472 - stop: (entityId: EntityId): InputEvent => 473 - ({ _tag: "Stop", entityId }), 474 - } 475 - 476 - /** 477 - * Process input event 478 - * 479 - * Pattern matching ensures all input types are handled 480 - */ 481 - const processInput = (state: GameState, input: InputEvent): GameState => { 482 - const updateEntity = (id: EntityId, updater: (e: GameEntity) => GameEntity): readonly GameEntity[] => 483 - state.entities.map(e => e.id === id ? updater(e) : e) 484 - 485 - return match(input)({ 486 - Move: ({ entityId, vx, vy }) => ({ 487 - ...state, 488 - entities: updateEntity(entityId, e => { 489 - // Only alive entities can move 490 - if (e.health <= 0) return e 491 - // Type-safe transition 492 - const idle = e as IdleEntity 493 - return startMoving(idle, pps(vx), pps(vy)) 494 - }), 495 - }), 496 - 497 - Attack: ({ entityId }) => ({ 498 - ...state, 499 - entities: updateEntity(entityId, e => { 500 - if (e.health <= 0) return e 501 - const alive = e as IdleEntity | MovingEntity 502 - return attack(alive) 503 - }), 504 - }), 505 - 506 - Stop: ({ entityId }) => ({ 507 - ...state, 508 - entities: updateEntity(entityId, e => { 509 - if (e.health <= 0) return e 510 - if ((e.vx as number) === 0 && (e.vy as number) === 0) return e 511 - const moving = e as MovingEntity 512 - return stopMoving(moving) 513 - }), 514 - }), 515 - }) 516 - } 517 - 518 - // ============================================================================= 519 - // COMBAT SYSTEM - Demonstrating typestate benefits 520 - // ============================================================================= 521 - 522 - /** 523 - * Combat round between two entities 524 - * 525 - * This demonstrates why typestate is powerful: 526 - * - Can only initiate combat with alive entities 527 - * - Result can transition entities to Dead 528 - * - All state changes are type-tracked 529 - */ 530 - const resolveCombat = ( 531 - attacker: IdleEntity | MovingEntity, 532 - defender: IdleEntity | MovingEntity | StunnedEntity, 533 - damage: number 534 - ): { attacker: AttackingEntity; defender: GameEntity } => { 535 - const attackingEntity = attack(attacker) 536 - const damagedDefender = takeDamage(defender, damage) 537 - 538 - return { 539 - attacker: attackingEntity, 540 - defender: damagedDefender, 541 - } 542 - } 543 - 544 - // ============================================================================= 545 - // DEMO 546 - // ============================================================================= 547 - 548 - const demo = async () => { 549 - console.log("╔═══════════════════════════════════════════════════════════════╗") 550 - console.log("║ GAME LOOP DEMO - purus-ts ║") 551 - console.log("╚═══════════════════════════════════════════════════════════════╝\n") 552 - 553 - // 1. Create entities 554 - console.log("─── 1. Create Game Entities ───") 555 - 556 - const player = createEntity("Player", 100, 100, 10) 557 - const enemy1 = createEntity("Goblin", 300, 150, 5) 558 - const enemy2 = createEntity("Orc", 200, 300, 5) 559 - const tree = createEntity("Tree", 400, 200, 1) 560 - 561 - console.log(" Created entities:") 562 - console.log(` - Player at (${player.x as number}, ${player.y as number})`) 563 - console.log(` - Goblin at (${enemy1.x as number}, ${enemy1.y as number})`) 564 - console.log(` - Orc at (${enemy2.x as number}, ${enemy2.y as number})`) 565 - console.log(` - Tree at (${tree.x as number}, ${tree.y as number})\n`) 566 - 567 - // 2. Demonstrate typestate transitions 568 - console.log("─── 2. Typestate Transitions ───") 569 - 570 - // Move the player 571 - const movingPlayer = startMoving(player, pps(50), pps(25)) 572 - console.log(` Player started moving: vx=${movingPlayer.vx}, vy=${movingPlayer.vy}`) 573 - 574 - // Attack from moving state 575 - const attackingPlayer = attack(movingPlayer) 576 - console.log(` Player is now attacking`) 577 - 578 - // Finish attack and go idle 579 - const idlePlayer = finishAttack(attackingPlayer) 580 - console.log(` Player finished attack, now idle`) 581 - 582 - // Combat - deal damage 583 - const combatResult = resolveCombat(idlePlayer, enemy1 as IdleEntity, 30) 584 - console.log(` Combat! Player attacks Goblin for 30 damage`) 585 - console.log(` Goblin health: ${combatResult.defender.health}/${enemy1.maxHealth}`) 586 - 587 - // Try to attack a dead entity - this would be a compile error: 588 - // const deadEnemy = takeDamage(enemy1 as IdleEntity, 200) as DeadEntity 589 - // attack(deadEnemy) // ERROR: Type 'DeadEntity' is not assignable 590 - 591 - console.log(" ✓ Typestate prevents attacking dead entities (compile-time check)\n") 592 - 593 - // 3. Unit type safety 594 - console.log("─── 3. Physical Unit Safety ───") 595 - 596 - const pos1 = pixels(100) 597 - const pos2 = pixels(50) 598 - const vel = pps(10) 599 - const time = seconds(0.5) 600 - 601 - const displacement = scaleVelocity(vel, time) 602 - const newPos = addPixels(pos1, displacement) 603 - 604 - console.log(` Position: ${pos1}px + (${vel}px/s × ${time}s) = ${newPos}px`) 605 - 606 - // These would be compile errors: 607 - // addPixels(pos1, vel) // ERROR: Pixels + PixelsPerSecond 608 - // scaleVelocity(pos1, time) // ERROR: Pixels is not velocity 609 - 610 - console.log(" ✓ Unit types prevent mixing position and velocity\n") 611 - 612 - // 4. Sorted array guarantee 613 - console.log("─── 4. Sorted Array for Rendering ───") 614 - 615 - const entities: readonly GameEntity[] = [enemy2, tree, player, enemy1] 616 - const sortedEntities = sortByZIndex(entities) 617 - 618 - console.log(" Render order (by z-index):") 619 - sortedEntities.forEach((e, i) => { 620 - console.log(` ${i + 1}. ${e.name} (z=${e.zIndex})`) 621 - }) 622 - 623 - // Check if non-empty to get topmost 624 - const nonEmptyResult = nonEmpty(sortedEntities) 625 - matchOption( 626 - (entities: Arr<GameEntity, "Sorted" | "NonEmpty">) => { 627 - const topmost = getTopmost(entities) 628 - console.log(` Topmost entity: ${topmost.name}`) 629 - }, 630 - () => console.log(" No entities to render") 631 - )(nonEmptyResult) 632 - 633 - console.log() 634 - 635 - // 5. Run game loop 636 - console.log("─── 5. Game Loop (120 frames ≈ 2 seconds) ───") 637 - 638 - const initialEntities: readonly GameEntity[] = [ 639 - startMoving(player, pps(30), pps(15)), 640 - startMoving(enemy1 as IdleEntity, pps(-20), pps(10)), 641 - enemy2, 642 - tree, 643 - ] 644 - 645 - const initialState = createGameState(initialEntities) 646 - 647 - const loopFiber = runFiber(gameLoop(initialState, 120), undefined) 648 - 649 - const finalState = await loopFiber.await() 650 - if (finalState._tag === "Success") { 651 - console.log(`\n ✓ Game loop completed: ${finalState.value.frameCount} frames`) 652 - } 653 - console.log() 654 - 655 - // 6. Input processing 656 - console.log("─── 6. Input Processing ───") 657 - 658 - let gameState = createGameState([player, enemy1]) 659 - 660 - // Simulate inputs 661 - const inputs: InputEvent[] = [ 662 - InputEvent.move(player.id, 100, 50), 663 - InputEvent.attack(player.id), 664 - InputEvent.stop(player.id), 665 - ] 666 - 667 - inputs.forEach(input => { 668 - gameState = processInput(gameState, input) 669 - console.log(` Processed: ${input._tag}`) 670 - }) 671 - 672 - console.log(" ✓ Pattern matching handles all input types\n") 673 - 674 - console.log("═══════════════════════════════════════════════════════════════") 675 - console.log("Demo complete!") 676 - } 677 - 678 - demo() 679 - .catch(console.error) 680 - .finally(() => process.exit(0))
-516
examples/http-client.ts
··· 1 - /** 2 - * Resilient HTTP Client 3 - * 4 - * Showcases: Effect composition, retry, timeout, race, typed error handling, dependency injection 5 - * 6 - * ## Why purus is better than vanilla JS/TS: 7 - * 8 - * 1. **Typed errors force handling all failure modes** 9 - * - Vanilla: `catch (e)` gives you `unknown`, easy to miss error cases 10 - * - Purus: `Eff<Response, HttpError, Env>` - compiler enforces handling NotFound, Timeout, etc. 11 - * 12 - * 2. **Race + timeout with automatic cleanup** 13 - * - Vanilla: Promise.race() doesn't cancel the loser, AbortController is manual 14 - * - Purus: `race()` automatically interrupts the losing fiber, cleanup runs 15 - * 16 - * 3. **Retry logic is composable, not scattered try/catch** 17 - * - Vanilla: Retry logic is copy-pasted, exponential backoff is complex 18 - * - Purus: `pipe(request, retry(3), timeout(5000))` - one line 19 - * 20 - * 4. **Dependency injection without class hierarchies** 21 - * - Vanilla: Constructor injection, DI frameworks, decorators 22 - * - Purus: `access<Env>()` + `provide(env)` - pure functions, no magic 23 - * 24 - * 5. **Parallel requests with fail-fast semantics** 25 - * - Vanilla: Promise.all() continues running failed requests 26 - * - Purus: `all()` cancels all on first failure, cleans up resources 27 - */ 28 - 29 - import { 30 - // Types 31 - type Eff, 32 - type Exit, 33 - type Branded, 34 - Exit, 35 - 36 - // Constructors 37 - succeed, 38 - fail, 39 - async, 40 - sync, 41 - 42 - // Transformations 43 - mapEff, 44 - flatMap, 45 - foldEff, 46 - catchAll, 47 - access, 48 - accessEff, 49 - provide, 50 - 51 - // Concurrency 52 - race, 53 - all, 54 - 55 - // Combinators 56 - sleep, 57 - timeout, 58 - retry, 59 - 60 - // Composition 61 - pipe, 62 - match, 63 - brand, 64 - 65 - // Runners 66 - runPromise, 67 - } from "../src/index" 68 - 69 - // ============================================================================= 70 - // DOMAIN TYPES - Branded IDs prevent mixing up different string types 71 - // ============================================================================= 72 - 73 - /** 74 - * URL branded type - can't accidentally pass a UserId where URL is expected 75 - * 76 - * Vanilla problem: 77 - * fetch(userId) // Compiles! But wrong at runtime 78 - * 79 - * Purus solution: 80 - * fetch(userId) // Type error! Expected Url, got UserId 81 - */ 82 - type Url = Branded<string, "Url"> 83 - const Url = (s: string): Url => brand(s) 84 - 85 - type RequestId = Branded<string, "RequestId"> 86 - const RequestId = (): RequestId => brand(crypto.randomUUID()) 87 - 88 - // ============================================================================= 89 - // TYPED ERROR HIERARCHY - Compiler forces you to handle all cases 90 - // ============================================================================= 91 - 92 - /* 93 - * Discriminated union of all possible HTTP errors 94 - * 95 - * Vanilla problem: 96 - * try { await fetch(url) } catch (e) { // what is e? unknown! } 97 - * 98 - * Purus solution: 99 - * The type signature `Eff<Response, HttpError, Env>` makes it clear 100 - * what errors can occur, and `match()` forces handling all of them 101 - */ 102 - type HttpError = 103 - | { readonly _tag: "NetworkError"; readonly message: string } 104 - | { readonly _tag: "TimeoutError"; readonly url: Url; readonly ms: number } 105 - | { readonly _tag: "NotFound"; readonly url: Url } 106 - | { readonly _tag: "Unauthorized"; readonly url: Url } 107 - | { readonly _tag: "RateLimited"; readonly url: Url; readonly retryAfter: number } 108 - | { readonly _tag: "ServerError"; readonly url: Url; readonly status: number } 109 - | { readonly _tag: "ParseError"; readonly message: string } 110 - 111 - const HttpError = { 112 - network: (message: string): HttpError => ({ _tag: "NetworkError", message }), 113 - timeout: (url: Url, ms: number): HttpError => ({ _tag: "TimeoutError", url, ms }), 114 - notFound: (url: Url): HttpError => ({ _tag: "NotFound", url }), 115 - unauthorized: (url: Url): HttpError => ({ _tag: "Unauthorized", url }), 116 - rateLimited: (url: Url, retryAfter: number): HttpError => ({ _tag: "RateLimited", url, retryAfter }), 117 - serverError: (url: Url, status: number): HttpError => ({ _tag: "ServerError", url, status }), 118 - parse: (message: string): HttpError => ({ _tag: "ParseError", message }), 119 - } 120 - 121 - // ============================================================================= 122 - // ENVIRONMENT TYPES - Dependency injection via type parameters 123 - // ============================================================================= 124 - 125 - /** 126 - * Services required by our HTTP client 127 - * 128 - * Vanilla problem: 129 - * - Constructor injection couples caller to implementation 130 - * - Global singletons make testing hard 131 - * - DI frameworks add magic and complexity 132 - * 133 - * Purus solution: 134 - * - `access<HttpEnv>()` reads from environment 135 - * - `provide(mockEnv)(effect)` injects mocks for testing 136 - * - No decorators, no reflection, just functions 137 - */ 138 - type Logger = { 139 - readonly debug: (msg: string) => void 140 - readonly info: (msg: string) => void 141 - readonly error: (msg: string) => void 142 - } 143 - 144 - type Metrics = { 145 - readonly requestStart: (id: RequestId, url: Url) => void 146 - readonly requestEnd: (id: RequestId, status: "success" | "failure", durationMs: number) => void 147 - } 148 - 149 - type HttpEnv = { 150 - readonly logger: Logger 151 - readonly metrics: Metrics 152 - readonly baseUrl: Url 153 - } 154 - 155 - // ============================================================================= 156 - // CORE HTTP PRIMITIVES 157 - // ============================================================================= 158 - 159 - /** 160 - * Low-level fetch wrapper with automatic cleanup on cancellation 161 - * 162 - * Vanilla problem: 163 - * - AbortController requires manual cleanup 164 - * - Easy to forget, causes memory leaks 165 - * - No standard way to propagate cancellation 166 - * 167 - * Purus solution: 168 - * - Return a cleanup function from `async()` 169 - * - Runtime automatically calls it on interrupt 170 - * - Cancellation propagates through the entire fiber tree 171 - */ 172 - const fetchWithAbort = (url: Url, options: RequestInit = {}): Eff<Response, HttpError, unknown> => 173 - async((resume) => { 174 - const controller = new AbortController() 175 - 176 - fetch(url, { ...options, signal: controller.signal }) 177 - .then(response => { 178 - if (response.ok) { 179 - resume(Exit.succeed(response)) 180 - } else if (response.status === 404) { 181 - resume(Exit.fail(HttpError.notFound(url))) 182 - } else if (response.status === 401) { 183 - resume(Exit.fail(HttpError.unauthorized(url))) 184 - } else if (response.status === 429) { 185 - const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10) 186 - resume(Exit.fail(HttpError.rateLimited(url, retryAfter))) 187 - } else if (response.status >= 500) { 188 - resume(Exit.fail(HttpError.serverError(url, response.status))) 189 - } else { 190 - resume(Exit.fail(HttpError.serverError(url, response.status))) 191 - } 192 - }) 193 - .catch(err => { 194 - if (err.name === "AbortError") { 195 - // Interrupted - don't resume, fiber is already dead 196 - return 197 - } 198 - resume(Exit.fail(HttpError.network(err.message))) 199 - }) 200 - 201 - // Cleanup function - called automatically on fiber interruption! 202 - return () => controller.abort() 203 - }) 204 - 205 - /** 206 - * Parse JSON response with typed error handling 207 - */ 208 - const parseJson = <T>(response: Response): Eff<T, HttpError, unknown> => 209 - async((resume) => { 210 - response 211 - .json() 212 - .then(data => resume(Exit.succeed(data as T))) 213 - .catch(err => resume(Exit.fail(HttpError.parse(err.message)))) 214 - }) 215 - 216 - // ============================================================================= 217 - // HIGH-LEVEL HTTP CLIENT - Composable, logged, metriced 218 - // ============================================================================= 219 - 220 - type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" 221 - 222 - type RequestConfig = { 223 - readonly method: HttpMethod 224 - readonly body?: unknown 225 - readonly headers?: Record<string, string> 226 - readonly timeoutMs?: number 227 - readonly retries?: number 228 - } 229 - 230 - /** 231 - * Make an HTTP request with logging, metrics, timeout, and retry 232 - * 233 - * This is where purus shines - look how cleanly this composes! 234 - * 235 - * Vanilla equivalent would be: 236 - * try { 237 - * const controller = new AbortController() 238 - * const timeoutId = setTimeout(() => controller.abort(), timeoutMs) 239 - * for (let attempt = 0; attempt < retries; attempt++) { 240 - * try { 241 - * logger.debug(`Attempt ${attempt}...`) 242 - * const response = await fetch(url, { signal: controller.signal }) 243 - * clearTimeout(timeoutId) 244 - * metrics.requestEnd(...) 245 - * return await response.json() 246 - * } catch (e) { 247 - * if (attempt === retries - 1) throw e 248 - * await sleep(1000 * Math.pow(2, attempt)) 249 - * } 250 - * } 251 - * } catch (e) { ... } 252 - * 253 - * With purus, it's just `pipe(fetch, retry(3), timeout(5000))` 254 - */ 255 - const request = <T>(path: string, config: RequestConfig = { method: "GET" }): Eff<T, HttpError, HttpEnv> => 256 - accessEff<HttpEnv, T, HttpError>(({ logger, metrics, baseUrl }) => { 257 - const url = Url(`${baseUrl}${path}`) 258 - const id = RequestId() 259 - const timeoutMs = config.timeoutMs ?? 30000 260 - const retries = config.retries ?? 3 261 - 262 - const startTime = Date.now() 263 - 264 - // Log the request 265 - const logStart = sync(() => { 266 - logger.info(`[${id}] ${config.method} ${url}`) 267 - metrics.requestStart(id, url) 268 - }) 269 - 270 - // The actual fetch + parse 271 - const doRequest = pipe( 272 - fetchWithAbort(url, { 273 - method: config.method, 274 - headers: { 275 - "Content-Type": "application/json", 276 - ...config.headers, 277 - }, 278 - body: config.body ? JSON.stringify(config.body) : undefined, 279 - }), 280 - flatMap(response => parseJson<T>(response)) 281 - ) 282 - 283 - // Compose: log → fetch → retry → timeout → log result 284 - return pipe( 285 - logStart, 286 - flatMap(() => 287 - pipe( 288 - doRequest, 289 - retry(retries), 290 - timeout(timeoutMs), 291 - // Convert timeout (null) to TimeoutError 292 - flatMap(result => 293 - result === null 294 - ? fail(HttpError.timeout(url, timeoutMs)) 295 - : succeed(result) 296 - ), 297 - // Log success/failure 298 - foldEff( 299 - error => { 300 - logger.error(`[${id}] Failed: ${error._tag}`) 301 - metrics.requestEnd(id, "failure", Date.now() - startTime) 302 - return fail(error) 303 - }, 304 - data => { 305 - logger.info(`[${id}] Success in ${Date.now() - startTime}ms`) 306 - metrics.requestEnd(id, "success", Date.now() - startTime) 307 - return succeed(data) 308 - } 309 - ) 310 - ) 311 - ) 312 - ) 313 - }) 314 - 315 - // Convenience methods 316 - const get = <T>(path: string, config?: Omit<RequestConfig, "method">): Eff<T, HttpError, HttpEnv> => 317 - request<T>(path, { ...config, method: "GET" }) 318 - 319 - const post = <T>(path: string, body: unknown, config?: Omit<RequestConfig, "method" | "body">): Eff<T, HttpError, HttpEnv> => 320 - request<T>(path, { ...config, method: "POST", body }) 321 - 322 - // ============================================================================= 323 - // ADVANCED PATTERNS 324 - // ============================================================================= 325 - 326 - /** 327 - * Race multiple servers - use the fastest response 328 - * 329 - * Vanilla problem: 330 - * - Promise.race() doesn't cancel slower requests 331 - * - They continue running, wasting resources 332 - * - Need manual AbortController coordination 333 - * 334 - * Purus solution: 335 - * - `race()` automatically interrupts the loser 336 - * - Cleanup functions run, connections close 337 - * - One line of code 338 - */ 339 - const fastestServer = <T>(urls: readonly Url[]): Eff<T, HttpError, unknown> => { 340 - if (urls.length === 0) { 341 - return fail(HttpError.network("No URLs provided")) 342 - } 343 - if (urls.length === 1) { 344 - return fetchWithAbort(urls[0]!).pipe(flatMap(r => parseJson<T>(r))) 345 - } 346 - 347 - const requests = urls.map(url => 348 - pipe( 349 - fetchWithAbort(url), 350 - flatMap(r => parseJson<T>(r)) 351 - ) 352 - ) 353 - 354 - // Race all requests - first to complete wins, others are cancelled 355 - return requests.reduce((a, b) => race(a, b)) 356 - } 357 - 358 - /** 359 - * Parallel batch requests with fail-fast 360 - * 361 - * Vanilla problem: 362 - * - Promise.all() waits for all, even after first failure 363 - * - Promise.allSettled() doesn't cancel on failure 364 - * - Manual coordination is complex and error-prone 365 - * 366 - * Purus solution: 367 - * - `all()` runs in parallel 368 - * - First failure cancels all other requests 369 - * - Resources are cleaned up automatically 370 - */ 371 - const batchGet = <T>(paths: readonly string[]): Eff<readonly T[], HttpError, HttpEnv> => 372 - all(paths.map(path => get<T>(path))) as Eff<readonly T[], HttpError, HttpEnv> 373 - 374 - /** 375 - * Retry with exponential backoff 376 - * 377 - * Vanilla: Complex loop with setTimeout, try/catch, counter management 378 - * Purus: Just compose `sleep` and `retry` effects 379 - */ 380 - const retryWithBackoff = <A, E, R>( 381 - effect: Eff<A, E, R>, 382 - maxAttempts: number, 383 - baseDelayMs: number 384 - ): Eff<A, E, R> => { 385 - const attempt = (n: number): Eff<A, E, R> => 386 - n >= maxAttempts 387 - ? effect 388 - : pipe( 389 - effect, 390 - catchAll(error => 391 - pipe( 392 - sleep(baseDelayMs * Math.pow(2, n)), 393 - flatMap(() => attempt(n + 1)) 394 - ) as Eff<A, E, R> 395 - ) 396 - ) as Eff<A, E, R> 397 - 398 - return attempt(0) 399 - } 400 - 401 - // ============================================================================= 402 - // ERROR HANDLING WITH PATTERN MATCHING 403 - // ============================================================================= 404 - 405 - /** 406 - * Handle all error cases exhaustively 407 - * 408 - * Vanilla problem: 409 - * - `catch (e: unknown)` - you don't know what errors can occur 410 - * - Easy to forget edge cases 411 - * - No compiler help 412 - * 413 - * Purus solution: 414 - * - `match()` requires handling ALL variants 415 - * - Add a new error type → compiler errors until you handle it 416 - * - Exhaustive by construction 417 - */ 418 - const handleHttpError = (error: HttpError): string => 419 - match(error)({ 420 - NetworkError: ({ message }) => `Network failed: ${message}`, 421 - TimeoutError: ({ url, ms }) => `Request to ${url} timed out after ${ms}ms`, 422 - NotFound: ({ url }) => `Resource not found: ${url}`, 423 - Unauthorized: ({ url }) => `Unauthorized access to ${url}`, 424 - RateLimited: ({ url, retryAfter }) => `Rate limited on ${url}, retry after ${retryAfter}s`, 425 - ServerError: ({ url, status }) => `Server error ${status} on ${url}`, 426 - ParseError: ({ message }) => `Failed to parse response: ${message}`, 427 - }) 428 - 429 - // ============================================================================= 430 - // DEMO - Run it! 431 - // ============================================================================= 432 - 433 - // Mock environment for demo 434 - const mockEnv: HttpEnv = { 435 - logger: { 436 - debug: msg => console.log(`[DEBUG] ${msg}`), 437 - info: msg => console.log(`[INFO] ${msg}`), 438 - error: msg => console.log(`[ERROR] ${msg}`), 439 - }, 440 - metrics: { 441 - requestStart: (id, url) => console.log(`[METRIC] Start: ${id} → ${url}`), 442 - requestEnd: (id, status, ms) => console.log(`[METRIC] End: ${id} ${status} (${ms}ms)`), 443 - }, 444 - baseUrl: Url("https://jsonplaceholder.typicode.com"), 445 - } 446 - 447 - // Example types 448 - type User = { id: number; name: string; email: string } 449 - type Post = { id: number; title: string; body: string } 450 - 451 - const demo = async () => { 452 - console.log("╔═══════════════════════════════════════════════════════════════╗") 453 - console.log("║ HTTP CLIENT DEMO - purus-ts ║") 454 - console.log("╚═══════════════════════════════════════════════════════════════╝\n") 455 - 456 - // 1. Simple GET request 457 - console.log("─── 1. Simple GET request ───") 458 - const getUser = pipe( 459 - get<User>("/users/1"), 460 - provide(mockEnv) 461 - ) 462 - 463 - try { 464 - const user = await runPromise(getUser) 465 - console.log(`Got user: ${user.name} (${user.email})\n`) 466 - } catch (e) { 467 - console.log(`Error: ${handleHttpError(e as HttpError)}\n`) 468 - } 469 - 470 - // 2. Parallel batch request 471 - console.log("─── 2. Parallel batch request ───") 472 - const batchUsers = pipe( 473 - batchGet<User>(["/users/1", "/users/2", "/users/3"]), 474 - provide(mockEnv) 475 - ) 476 - 477 - try { 478 - const users = await runPromise(batchUsers) 479 - console.log(`Got ${users.length} users: ${users.map(u => u.name).join(", ")}\n`) 480 - } catch (e) { 481 - console.log(`Error: ${handleHttpError(e as HttpError)}\n`) 482 - } 483 - 484 - // 3. Request with error handling 485 - console.log("─── 3. Request with error (404) ───") 486 - const notFoundRequest = pipe( 487 - get<User>("/users/99999"), 488 - catchAll(error => succeed({ id: 0, name: "Default User", email: "default@example.com" })), 489 - provide(mockEnv) 490 - ) 491 - 492 - const result = await runPromise(notFoundRequest) 493 - console.log(`Got (with fallback): ${result.name}\n`) 494 - 495 - // 4. Timeout demo 496 - console.log("─── 4. Timeout (will timeout quickly) ───") 497 - const slowRequest = pipe( 498 - get<User>("/users/1", { timeoutMs: 1 }), // 1ms timeout - will fail 499 - catchAll(error => { 500 - console.log(` Caught: ${handleHttpError(error)}`) 501 - return succeed({ id: 0, name: "Timeout Fallback", email: "" }) 502 - }), 503 - provide(mockEnv) 504 - ) 505 - 506 - const timeoutResult = await runPromise(slowRequest) 507 - console.log(` Result: ${timeoutResult.name}\n`) 508 - 509 - console.log("═══════════════════════════════════════════════════════════════") 510 - console.log("Demo complete!") 511 - } 512 - 513 - // Run if this is the main module 514 - demo() 515 - .catch(console.error) 516 - .finally(() => process.exit(0))
-596
examples/parallel-scraper.ts
··· 1 - /** 2 - * Parallel Web Scraper with Concurrency Control 3 - * 4 - * Showcases: fork, all, race, timeout, retry, fiber supervision, graceful shutdown 5 - * 6 - * ## Why purus is better than vanilla JS/TS: 7 - * 8 - * 1. **True cancellation with AbortController wired automatically** 9 - * - Vanilla: Manual AbortController creation, passing, cleanup 10 - * - Purus: Return cleanup function from `async()`, runtime handles abort 11 - * 12 - * 2. **Concurrency limit without semaphore boilerplate** 13 - * - Vanilla: Complex semaphore implementation, async/await coordination 14 - * - Purus: Fork workers, coordinate with fibers 15 - * 16 - * 3. **Progress is just fiber status inspection** 17 - * - Vanilla: Manual progress tracking, callbacks, event emitters 18 - * - Purus: `fiber.status()` returns current state 19 - * 20 - * 4. **Shutdown interrupts ALL in-flight requests** 21 - * - Vanilla: Track all pending promises, call abort on each, handle races 22 - * - Purus: Interrupt parent fiber → all children automatically interrupted 23 - * 24 - * 5. **Retry with timeout per request** 25 - * - Vanilla: Nested try/catch, setTimeout, Promise.race, manual retry count 26 - * - Purus: `pipe(fetch, timeout(5000), retry(3))` - composable 27 - * 28 - * 6. **Race between mirrors for redundancy** 29 - * - Vanilla: Promise.race doesn't cancel losers 30 - * - Purus: First to succeed wins, losers are interrupted and cleaned up 31 - */ 32 - 33 - import { 34 - // Types 35 - type Eff, 36 - type Exit, 37 - type Fiber, 38 - type FiberStatus, 39 - type Branded, 40 - Exit, 41 - 42 - // Constructors 43 - succeed, 44 - fail, 45 - async, 46 - sync, 47 - 48 - // Transformations 49 - mapEff, 50 - flatMap, 51 - foldEff, 52 - catchAll, 53 - 54 - // Concurrency 55 - fork, 56 - join, 57 - race, 58 - all, 59 - interruptFiber, 60 - 61 - // Combinators 62 - sleep, 63 - timeout, 64 - retry, 65 - tapEff, 66 - 67 - // Composition 68 - pipe, 69 - match, 70 - brand, 71 - 72 - // Runners 73 - runFiber, 74 - runPromise, 75 - runPromiseExit, 76 - } from "../src/index" 77 - 78 - // ============================================================================= 79 - // DOMAIN TYPES 80 - // ============================================================================= 81 - 82 - type Url = Branded<string, "Url"> 83 - const Url = (s: string): Url => brand(s) 84 - 85 - type ScraperId = Branded<string, "ScraperId"> 86 - const ScraperId = (): ScraperId => brand(`scraper-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) 87 - 88 - // ============================================================================= 89 - // SCRAPER ERRORS 90 - // ============================================================================= 91 - 92 - type ScrapeError = 93 - | { readonly _tag: "NetworkError"; readonly url: Url; readonly message: string } 94 - | { readonly _tag: "TimeoutError"; readonly url: Url; readonly ms: number } 95 - | { readonly _tag: "ParseError"; readonly url: Url; readonly message: string } 96 - | { readonly _tag: "RateLimited"; readonly url: Url; readonly retryAfterMs: number } 97 - | { readonly _tag: "Interrupted" } 98 - 99 - const ScrapeError = { 100 - network: (url: Url, message: string): ScrapeError => ({ _tag: "NetworkError", url, message }), 101 - timeout: (url: Url, ms: number): ScrapeError => ({ _tag: "TimeoutError", url, ms }), 102 - parse: (url: Url, message: string): ScrapeError => ({ _tag: "ParseError", url, message }), 103 - rateLimited: (url: Url, retryAfterMs: number): ScrapeError => ({ _tag: "RateLimited", url, retryAfterMs }), 104 - interrupted: (): ScrapeError => ({ _tag: "Interrupted" }), 105 - } 106 - 107 - // ============================================================================= 108 - // CORE SCRAPING PRIMITIVES 109 - // ============================================================================= 110 - 111 - /** 112 - * Fetch with automatic abort on cancellation 113 - * 114 - * Vanilla problem: 115 - * ```ts 116 - * const controller = new AbortController() 117 - * try { 118 - * const response = await fetch(url, { signal: controller.signal }) 119 - * // ... 120 - * } finally { 121 - * // Who calls controller.abort()? When? 122 - * // Easy to leak connections on errors 123 - * } 124 - * ``` 125 - * 126 - * Purus solution: 127 - * The cleanup function returned from `async()` is called automatically 128 - * when the fiber is interrupted. No manual tracking needed! 129 - */ 130 - const fetchWithAbort = (url: Url): Eff<string, ScrapeError, unknown> => 131 - async((resume) => { 132 - const controller = new AbortController() 133 - 134 - fetch(url, { signal: controller.signal }) 135 - .then(async response => { 136 - if (response.status === 429) { 137 - const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10) 138 - resume(Exit.fail(ScrapeError.rateLimited(url, retryAfter * 1000))) 139 - } else if (!response.ok) { 140 - resume(Exit.fail(ScrapeError.network(url, `HTTP ${response.status}`))) 141 - } else { 142 - const text = await response.text() 143 - resume(Exit.succeed(text)) 144 - } 145 - }) 146 - .catch(err => { 147 - if (err.name === "AbortError") { 148 - // Fiber was interrupted - don't resume 149 - return 150 - } 151 - resume(Exit.fail(ScrapeError.network(url, err.message))) 152 - }) 153 - 154 - // Cleanup - called automatically on interrupt! 155 - return () => controller.abort() 156 - }) 157 - 158 - /** 159 - * Scrape a single URL with timeout and retry 160 - */ 161 - const scrapeUrl = ( 162 - url: Url, 163 - options: { timeoutMs?: number; retries?: number } = {} 164 - ): Eff<string, ScrapeError, unknown> => { 165 - const timeoutMs = options.timeoutMs ?? 10000 166 - const retries = options.retries ?? 3 167 - 168 - return pipe( 169 - fetchWithAbort(url), 170 - // Add timeout 171 - timeout(timeoutMs), 172 - flatMap(result => 173 - result === null 174 - ? fail(ScrapeError.timeout(url, timeoutMs)) 175 - : succeed(result) 176 - ), 177 - // Retry on failure (except rate limiting) 178 - foldEff( 179 - error => 180 - error._tag === "RateLimited" 181 - ? fail(error) // Don't retry rate limits 182 - : fail(error), 183 - html => succeed(html) 184 - ), 185 - retry(retries) 186 - ) 187 - } 188 - 189 - // ============================================================================= 190 - // CONCURRENCY PATTERNS 191 - // ============================================================================= 192 - 193 - /** 194 - * Worker pool with concurrency limit 195 - * 196 - * Vanilla problem: 197 - * ```ts 198 - * async function scrapeWithLimit(urls, limit) { 199 - * const results = [] 200 - * const executing = new Set() 201 - * 202 - * for (const url of urls) { 203 - * const promise = fetchUrl(url).then(result => { 204 - * executing.delete(promise) 205 - * return result 206 - * }) 207 - * executing.add(promise) 208 - * 209 - * if (executing.size >= limit) { 210 - * await Promise.race(executing) 211 - * } 212 - * } 213 - * 214 - * return Promise.all(results) 215 - * } 216 - * // Complex, error-prone, hard to cancel 217 - * ``` 218 - * 219 - * Purus solution: 220 - * Fork worker fibers, use a simple queue pattern. 221 - * Interruption propagates to all workers automatically. 222 - */ 223 - type ScrapeResult = { 224 - readonly url: Url 225 - readonly result: { readonly _tag: "Success"; readonly html: string } | { readonly _tag: "Error"; readonly error: ScrapeError } 226 - } 227 - 228 - const scrapeWithConcurrency = ( 229 - urls: readonly Url[], 230 - concurrency: number, 231 - options: { timeoutMs?: number; retries?: number } = {} 232 - ): Eff<readonly ScrapeResult[], never, unknown> => { 233 - // Shared mutable state for the queue 234 - let queue = [...urls] 235 - const results: ScrapeResult[] = [] 236 - 237 - const worker = (workerId: number): Eff<void, never, unknown> => { 238 - const processNext = (): Eff<void, never, unknown> => { 239 - const url = queue.shift() 240 - if (!url) { 241 - return succeed(undefined) 242 - } 243 - 244 - return pipe( 245 - sync(() => console.log(` [Worker ${workerId}] Scraping ${url}`)), 246 - flatMap(() => scrapeUrl(url, options)), 247 - foldEff( 248 - error => { 249 - results.push({ url, result: { _tag: "Error", error } }) 250 - console.log(` [Worker ${workerId}] Failed: ${url} - ${error._tag}`) 251 - return succeed(undefined) 252 - }, 253 - html => { 254 - results.push({ url, result: { _tag: "Success", html } }) 255 - console.log(` [Worker ${workerId}] Success: ${url} (${html.length} chars)`) 256 - return succeed(undefined) 257 - } 258 - ), 259 - flatMap(() => processNext()) 260 - ) as Eff<void, never, unknown> 261 - } 262 - 263 - return processNext() 264 - } 265 - 266 - // Fork `concurrency` workers 267 - const workers = Array.from({ length: concurrency }, (_, i) => 268 - fork(worker(i + 1)) 269 - ) 270 - 271 - return pipe( 272 - // Fork all workers 273 - all(workers), 274 - // Wait for all to complete 275 - flatMap((fibers: readonly Fiber<void, never>[]) => 276 - all(fibers.map(f => join(f))) 277 - ), 278 - // Return results 279 - mapEff(() => results as readonly ScrapeResult[]) 280 - ) as Eff<readonly ScrapeResult[], never, unknown> 281 - } 282 - 283 - // ============================================================================= 284 - // MIRROR RACING - First successful response wins 285 - // ============================================================================= 286 - 287 - /** 288 - * Race multiple mirrors for redundancy 289 - * 290 - * Vanilla problem: 291 - * ```ts 292 - * const result = await Promise.race([ 293 - * fetch(mirror1), 294 - * fetch(mirror2), 295 - * fetch(mirror3), 296 - * ]) 297 - * // The other two fetches continue running! Wasted bandwidth. 298 - * // Need manual AbortController coordination. 299 - * ``` 300 - * 301 - * Purus solution: 302 - * `race()` automatically interrupts the losers. 303 - * Their cleanup functions run, connections are aborted. 304 - */ 305 - const scrapeFromMirrors = ( 306 - mirrors: readonly Url[], 307 - options: { timeoutMs?: number } = {} 308 - ): Eff<string, ScrapeError, unknown> => { 309 - if (mirrors.length === 0) { 310 - return fail(ScrapeError.network(Url(""), "No mirrors provided")) 311 - } 312 - 313 - const requests = mirrors.map(url => 314 - pipe( 315 - scrapeUrl(url, options), 316 - tapEff(() => console.log(` Mirror ${url} won the race!`)) 317 - ) 318 - ) 319 - 320 - // Race all - first success wins, others are cancelled 321 - return requests.reduce((a, b) => race(a, b)) 322 - } 323 - 324 - // ============================================================================= 325 - // PROGRESS TRACKING 326 - // ============================================================================= 327 - 328 - type ScrapeProgress = { 329 - readonly total: number 330 - readonly completed: number 331 - readonly failed: number 332 - readonly inProgress: number 333 - } 334 - 335 - /** 336 - * Scrape with progress tracking 337 - * 338 - * Fiber status is inspectable - no callbacks or events needed! 339 - */ 340 - const scrapeWithProgress = ( 341 - urls: readonly Url[], 342 - concurrency: number, 343 - onProgress: (progress: ScrapeProgress) => void 344 - ): Eff<readonly ScrapeResult[], never, unknown> => { 345 - let completed = 0 346 - let failed = 0 347 - 348 - const reportProgress = () => { 349 - onProgress({ 350 - total: urls.length, 351 - completed, 352 - failed, 353 - inProgress: urls.length - completed - failed, 354 - }) 355 - } 356 - 357 - // Wrap each URL scrape with progress reporting 358 - const scrapeWithReport = (url: Url): Eff<ScrapeResult, never, unknown> => 359 - pipe( 360 - scrapeUrl(url), 361 - foldEff( 362 - error => { 363 - failed++ 364 - reportProgress() 365 - return succeed({ url, result: { _tag: "Error" as const, error } }) 366 - }, 367 - html => { 368 - completed++ 369 - reportProgress() 370 - return succeed({ url, result: { _tag: "Success" as const, html } }) 371 - } 372 - ) 373 - ) as Eff<ScrapeResult, never, unknown> 374 - 375 - // Use worker pool pattern 376 - let queue = [...urls] 377 - const results: ScrapeResult[] = [] 378 - 379 - const worker = (): Eff<void, never, unknown> => { 380 - const processNext = (): Eff<void, never, unknown> => { 381 - const url = queue.shift() 382 - if (!url) return succeed(undefined) 383 - 384 - return pipe( 385 - scrapeWithReport(url), 386 - flatMap(result => { 387 - results.push(result) 388 - return processNext() 389 - }) 390 - ) as Eff<void, never, unknown> 391 - } 392 - return processNext() 393 - } 394 - 395 - const workers = Array.from({ length: concurrency }, () => fork(worker())) 396 - 397 - return pipe( 398 - sync(() => reportProgress()), // Initial progress 399 - flatMap(() => all(workers)), 400 - flatMap((fibers: readonly Fiber<void, never>[]) => 401 - all(fibers.map(f => join(f))) 402 - ), 403 - mapEff(() => results as readonly ScrapeResult[]) 404 - ) as Eff<readonly ScrapeResult[], never, unknown> 405 - } 406 - 407 - // ============================================================================= 408 - // GRACEFUL SHUTDOWN 409 - // ============================================================================= 410 - 411 - /** 412 - * Scraper with graceful shutdown support 413 - * 414 - * Vanilla problem: 415 - * ```ts 416 - * // How to stop all in-flight requests? 417 - * // Need to track every promise, every AbortController 418 - * // Race conditions between shutdown and completion 419 - * ``` 420 - * 421 - * Purus solution: 422 - * Interrupt the main fiber → all child fibers are interrupted 423 - * → all cleanup functions run → connections are aborted 424 - */ 425 - type ScraperHandle = { 426 - readonly id: ScraperId 427 - readonly fiber: Fiber<readonly ScrapeResult[], never> 428 - readonly shutdown: () => Eff<void, never, unknown> 429 - readonly getProgress: () => ScrapeProgress 430 - } 431 - 432 - const createScraper = ( 433 - urls: readonly Url[], 434 - concurrency: number 435 - ): Eff<ScraperHandle, never, unknown> => { 436 - let progress: ScrapeProgress = { 437 - total: urls.length, 438 - completed: 0, 439 - failed: 0, 440 - inProgress: 0, 441 - } 442 - 443 - const id = ScraperId() 444 - 445 - const scrapeEffect = scrapeWithProgress( 446 - urls, 447 - concurrency, 448 - p => { progress = p } 449 - ) 450 - 451 - return pipe( 452 - fork(scrapeEffect), 453 - mapEff(fiber => ({ 454 - id, 455 - fiber, 456 - shutdown: () => { 457 - console.log(` [${id}] Shutting down...`) 458 - return interruptFiber(fiber) 459 - }, 460 - getProgress: () => progress, 461 - })) 462 - ) 463 - } 464 - 465 - // ============================================================================= 466 - // DEMO 467 - // ============================================================================= 468 - 469 - const demo = async () => { 470 - console.log("╔═══════════════════════════════════════════════════════════════╗") 471 - console.log("║ PARALLEL SCRAPER DEMO - purus-ts ║") 472 - console.log("╚═══════════════════════════════════════════════════════════════╝\n") 473 - 474 - // 1. Basic parallel scraping 475 - console.log("─── 1. Parallel Scraping with Concurrency ───") 476 - 477 - const urls = [ 478 - Url("https://httpbin.org/delay/1"), 479 - Url("https://httpbin.org/delay/2"), 480 - Url("https://httpbin.org/status/404"), 481 - Url("https://httpbin.org/get"), 482 - Url("https://httpbin.org/html"), 483 - ] 484 - 485 - const parallelResult = await runPromiseExit( 486 - scrapeWithConcurrency(urls.slice(0, 3), 2, { timeoutMs: 5000 }) 487 - ) 488 - 489 - if (parallelResult._tag === "Success") { 490 - const results = parallelResult.value 491 - const successes = results.filter(r => r.result._tag === "Success").length 492 - const failures = results.filter(r => r.result._tag === "Error").length 493 - console.log(` ✓ Completed: ${successes} success, ${failures} failures\n`) 494 - } 495 - 496 - // 2. Mirror racing 497 - console.log("─── 2. Mirror Racing ───") 498 - 499 - const mirrors = [ 500 - Url("https://httpbin.org/delay/3"), 501 - Url("https://httpbin.org/delay/1"), // This should win 502 - Url("https://httpbin.org/delay/2"), 503 - ] 504 - 505 - console.log(" Racing 3 mirrors (delays: 3s, 1s, 2s)...") 506 - const raceStart = Date.now() 507 - 508 - const raceResult = await runPromiseExit( 509 - scrapeFromMirrors(mirrors, { timeoutMs: 10000 }) 510 - ) 511 - 512 - const raceTime = Date.now() - raceStart 513 - if (raceResult._tag === "Success") { 514 - console.log(` ✓ Got response in ${raceTime}ms (fastest mirror won)`) 515 - console.log(` ✓ Other mirrors were automatically cancelled\n`) 516 - } 517 - 518 - // 3. Progress tracking 519 - console.log("─── 3. Progress Tracking ───") 520 - 521 - const progressUrls = [ 522 - Url("https://httpbin.org/get"), 523 - Url("https://httpbin.org/html"), 524 - Url("https://httpbin.org/json"), 525 - ] 526 - 527 - const progressResult = await runPromiseExit( 528 - scrapeWithProgress( 529 - progressUrls, 530 - 2, 531 - progress => { 532 - const pct = Math.round((progress.completed / progress.total) * 100) 533 - console.log(` Progress: ${pct}% (${progress.completed}/${progress.total})`) 534 - } 535 - ) 536 - ) 537 - 538 - if (progressResult._tag === "Success") { 539 - console.log(` ✓ All ${progressResult.value.length} URLs processed\n`) 540 - } 541 - 542 - // 4. Graceful shutdown 543 - console.log("─── 4. Graceful Shutdown ───") 544 - 545 - const slowUrls = [ 546 - Url("https://httpbin.org/delay/5"), 547 - Url("https://httpbin.org/delay/5"), 548 - Url("https://httpbin.org/delay/5"), 549 - Url("https://httpbin.org/delay/5"), 550 - ] 551 - 552 - const scraperHandle = await runPromise(createScraper(slowUrls, 2)) 553 - console.log(` Started scraper ${scraperHandle.id}`) 554 - 555 - // Let it run for 1 second, then shutdown 556 - await new Promise(resolve => setTimeout(resolve, 1000)) 557 - 558 - const progress = scraperHandle.getProgress() 559 - console.log(` Progress before shutdown: ${progress.completed}/${progress.total}`) 560 - 561 - await runPromise(scraperHandle.shutdown()) 562 - console.log(" ✓ Shutdown complete - all in-flight requests cancelled\n") 563 - 564 - // 5. Error handling 565 - console.log("─── 5. Error Handling ───") 566 - 567 - const errorUrl = Url("https://httpbin.org/status/500") 568 - const errorResult = await runPromiseExit( 569 - pipe( 570 - scrapeUrl(errorUrl, { retries: 2, timeoutMs: 5000 }), 571 - catchAll(error => { 572 - console.log(` Caught error: ${match(error)({ 573 - NetworkError: ({ url, message }) => `Network error on ${url}: ${message}`, 574 - TimeoutError: ({ url, ms }) => `Timeout after ${ms}ms on ${url}`, 575 - ParseError: ({ url, message }) => `Parse error on ${url}: ${message}`, 576 - RateLimited: ({ url, retryAfterMs }) => `Rate limited on ${url}, retry after ${retryAfterMs}ms`, 577 - Interrupted: () => `Scrape was interrupted`, 578 - })}`) 579 - return succeed("Fallback content") 580 - }) 581 - ) 582 - ) 583 - 584 - if (errorResult._tag === "Success") { 585 - console.log(` ✓ Error handled gracefully\n`) 586 - } 587 - 588 - console.log("═══════════════════════════════════════════════════════════════") 589 - console.log("Demo complete!") 590 - } 591 - 592 - // Note: This demo requires network access 593 - // For offline testing, mock the fetch function 594 - demo() 595 - .catch(console.error) 596 - .finally(() => process.exit(0))
-768
examples/reactive-dom.ts
··· 1 - /** 2 - * Reactive DOM with Cancellation 3 - * 4 - * Showcases: Fiber cancellation, async effects, resource cleanup, typestate 5 - * 6 - * ## Why purus is better than vanilla JS/TS: 7 - * 8 - * 1. **Event listeners are automatically cleaned up when fiber is cancelled** 9 - * - Vanilla: `addEventListener` + manual `removeEventListener` tracking 10 - * - Purus: Return cleanup function from `async()` - runtime handles it 11 - * 12 - * 2. **No manual removeEventListener tracking** 13 - * - Vanilla: Easy to leak event listeners, especially in component unmount 14 - * - Purus: Cleanup is automatic and guaranteed 15 - * 16 - * 3. **Typestate prevents submitting invalid forms at compile time** 17 - * - Vanilla: `if (form.isValid) form.submit()` - runtime check 18 - * - Purus: `submit(form: Form<Valid>)` - can't call with invalid form 19 - * 20 - * 4. **Debounce/throttle compose naturally** 21 - * - Vanilla: Separate debounce utility, wrapper functions, manual timing 22 - * - Purus: `pipe(inputEvents, debounce(300))` - just effect composition 23 - * 24 - * 5. **Race between user actions** 25 - * - Vanilla: Complex state management to track which action won 26 - * - Purus: `race(onSubmit, onCancel)` - first to complete wins 27 - * 28 - * 6. **Subscription lifecycle is explicit and safe** 29 - * - Vanilla: Callbacks, cleanup functions, useEffect return values 30 - * - Purus: Fiber interruption propagates through entire subscription tree 31 - */ 32 - 33 - import { 34 - // Types 35 - type Eff, 36 - Exit, 37 - type Fiber, 38 - type Entity, 39 - type Result, 40 - type Option, 41 - 42 - // Constructors 43 - succeed, 44 - fail, 45 - async, 46 - sync, 47 - entity, 48 - transition, 49 - ok, 50 - err, 51 - some, 52 - none, 53 - 54 - // Transformations 55 - mapEff, 56 - flatMap, 57 - foldEff, 58 - catchAll, 59 - 60 - // Concurrency 61 - fork, 62 - join, 63 - race, 64 - interruptFiber, 65 - 66 - // Combinators 67 - sleep, 68 - timeout, 69 - 70 - // Composition 71 - pipe, 72 - match, 73 - 74 - // Runners 75 - runFiber, 76 - runPromise, 77 - } from "../src/index" 78 - 79 - // ============================================================================= 80 - // DOM EVENT PRIMITIVES - Events as Effects with automatic cleanup 81 - // ============================================================================= 82 - 83 - /** 84 - * Listen to a DOM event, returning cleanup function 85 - * 86 - * Vanilla problem: 87 - * ```ts 88 - * const handler = (e) => { ... } 89 - * element.addEventListener('click', handler) 90 - * // Later... but when? Who calls this? Easy to forget! 91 - * element.removeEventListener('click', handler) 92 - * ``` 93 - * 94 - * Purus solution: 95 - * The cleanup function is returned from `async()` and automatically 96 - * called when the fiber is interrupted. No manual tracking needed! 97 - */ 98 - const fromEvent = <E extends Event>( 99 - target: EventTarget, 100 - eventType: string 101 - ): Eff<E, never, unknown> => 102 - async((resume) => { 103 - const handler = (event: Event) => resume(Exit.succeed(event as E)) 104 - target.addEventListener(eventType, handler) 105 - 106 - // Cleanup - called automatically on fiber interrupt! 107 - return () => target.removeEventListener(eventType, handler) 108 - }) 109 - 110 - /** 111 - * Wait for next click on an element 112 - */ 113 - const onClick = (element: EventTarget): Eff<MouseEvent, never, unknown> => 114 - fromEvent<MouseEvent>(element, "click") 115 - 116 - /** 117 - * Wait for next input event 118 - */ 119 - const onInput = (element: EventTarget): Eff<InputEvent, never, unknown> => 120 - fromEvent<InputEvent>(element, "input") 121 - 122 - /** 123 - * Wait for next keydown 124 - */ 125 - const onKeyDown = (element: EventTarget): Eff<KeyboardEvent, never, unknown> => 126 - fromEvent<KeyboardEvent>(element, "keydown") 127 - 128 - /** 129 - * Wait for form submission 130 - */ 131 - const onSubmit = (form: EventTarget): Eff<SubmitEvent, never, unknown> => 132 - fromEvent<SubmitEvent>(form, "submit") 133 - 134 - // ============================================================================= 135 - // EFFECT COMBINATORS FOR DOM - Debounce, Throttle, etc. 136 - // ============================================================================= 137 - 138 - /** 139 - * Debounce an effect - wait for quiet period before emitting 140 - * 141 - * Vanilla problem: 142 - * ```ts 143 - * let timeoutId: number | null = null 144 - * input.addEventListener('input', (e) => { 145 - * if (timeoutId) clearTimeout(timeoutId) 146 - * timeoutId = setTimeout(() => { 147 - * // Actually handle the event 148 - * }, 300) 149 - * }) 150 - * // And don't forget cleanup on unmount! 151 - * ``` 152 - * 153 - * Purus solution: 154 - * Debouncing is just racing against a sleep timer! 155 - * First event to arrive after the quiet period wins. 156 - */ 157 - const debounce = <A, E, R>(ms: number) => 158 - (effect: Eff<A, E, R>): Eff<A, E, R> => 159 - pipe( 160 - sleep(ms), 161 - flatMap(() => effect) 162 - ) as Eff<A, E, R> 163 - 164 - /** 165 - * Throttle - emit at most once per time window 166 - */ 167 - const throttle = <A, E, R>(ms: number) => 168 - (effect: Eff<A, E, R>): Eff<A, E, R> => 169 - pipe( 170 - effect, 171 - flatMap(value => 172 - pipe( 173 - sleep(ms), 174 - mapEff(() => value) 175 - ) 176 - ) 177 - ) as Eff<A, E, R> 178 - 179 - /** 180 - * Take first N events then stop 181 - */ 182 - const take = (n: number) => 183 - <A, E, R>(getEvent: () => Eff<A, E, R>): Eff<readonly A[], E, R> => { 184 - const loop = (remaining: number, acc: readonly A[]): Eff<readonly A[], E, R> => 185 - remaining <= 0 186 - ? succeed(acc) 187 - : pipe( 188 - getEvent(), 189 - flatMap(a => loop(remaining - 1, [...acc, a])) 190 - ) as Eff<readonly A[], E, R> 191 - 192 - return loop(n, []) 193 - } 194 - 195 - /** 196 - * Repeat while predicate is true 197 - */ 198 - const takeWhile = <A, E, R>( 199 - predicate: (a: A) => boolean 200 - ) => 201 - (getEvent: () => Eff<A, E, R>): Eff<readonly A[], E, R> => { 202 - const loop = (acc: readonly A[]): Eff<readonly A[], E, R> => 203 - pipe( 204 - getEvent(), 205 - flatMap(a => 206 - predicate(a) 207 - ? loop([...acc, a]) 208 - : succeed(acc) 209 - ) 210 - ) as Eff<readonly A[], E, R> 211 - 212 - return loop([]) 213 - } 214 - 215 - // ============================================================================= 216 - // FORM TYPESTATE - Valid forms can't be submitted, invalid forms can't be processed 217 - // ============================================================================= 218 - 219 - /** 220 - * Form state machine via phantom types 221 - * 222 - * Vanilla problem: 223 - * ```ts 224 - * const form = { 225 - * isValid: boolean, 226 - * values: {...}, 227 - * errors: {...}, 228 - * } 229 - * 230 - * function submit(form: Form) { 231 - * if (!form.isValid) throw new Error("...") // Runtime check! 232 - * // submit... 233 - * } 234 - * ``` 235 - * 236 - * Purus solution: 237 - * ```ts 238 - * function submit(form: Form<Valid>) { 239 - * // Can ONLY be called with a valid form - enforced at compile time! 240 - * } 241 - * 242 - * // Compile error: Type 'Form<Invalid>' is not assignable to 'Form<Valid>' 243 - * submit(invalidForm) 244 - * ``` 245 - */ 246 - 247 - type Empty = "Empty" 248 - type Invalid = "Invalid" 249 - type Valid = "Valid" 250 - type Submitting = "Submitting" 251 - type Submitted = "Submitted" 252 - 253 - type FormData<T> = { 254 - readonly values: T 255 - readonly errors: Partial<Record<keyof T, string>> 256 - readonly touched: Partial<Record<keyof T, boolean>> 257 - } 258 - 259 - type EmptyForm<T> = Entity<FormData<T>, Empty> 260 - type InvalidForm<T> = Entity<FormData<T>, Invalid> 261 - type ValidForm<T> = Entity<FormData<T>, Valid> 262 - type SubmittingForm<T> = Entity<FormData<T>, Submitting> 263 - type SubmittedForm<T> = Entity<FormData<T>, Submitted> 264 - 265 - // Form state constructors 266 - const emptyForm = <T>(initial: T): EmptyForm<T> => 267 - entity({ values: initial, errors: {}, touched: {} }) 268 - 269 - // Form state transitions 270 - const toInvalid = <T, _S extends Empty | Valid>() => 271 - transition<FormData<T>, _S, Invalid>() 272 - 273 - const toValid = <T, _S extends Empty | Invalid>() => 274 - transition<FormData<T>, _S, Valid>() 275 - 276 - const toSubmitting = <T>() => 277 - transition<FormData<T>, Valid, Submitting>() 278 - 279 - const toSubmitted = <T>() => 280 - transition<FormData<T>, Submitting, Submitted>() 281 - 282 - /** 283 - * Validate form and transition to Valid or Invalid state 284 - */ 285 - type ValidationRule<T, K extends keyof T> = (value: T[K]) => Option<string> 286 - 287 - const validate = <T>( 288 - form: EmptyForm<T> | InvalidForm<T> | ValidForm<T>, 289 - rules: Partial<{ [K in keyof T]: ValidationRule<T, K> }> 290 - ): ValidForm<T> | InvalidForm<T> => { 291 - const errors: Partial<Record<keyof T, string>> = {} 292 - let hasErrors = false 293 - 294 - for (const key of Object.keys(rules) as Array<keyof T>) { 295 - const rule = rules[key] as ValidationRule<T, typeof key> 296 - const result = rule(form.values[key]) 297 - if (result._tag === "Some") { 298 - errors[key] = result.value 299 - hasErrors = true 300 - } 301 - } 302 - 303 - const newForm = { 304 - ...form, 305 - errors, 306 - touched: Object.keys(form.values as object).reduce( 307 - (acc, key) => ({ ...acc, [key]: true }), 308 - {} as Partial<Record<keyof T, boolean>> 309 - ), 310 - } 311 - 312 - return hasErrors 313 - ? entity<FormData<T>, Invalid>(newForm) 314 - : entity<FormData<T>, Valid>(newForm) 315 - } 316 - 317 - // ============================================================================= 318 - // INTERACTIVE FORM EXAMPLE 319 - // ============================================================================= 320 - 321 - type LoginFormValues = { 322 - readonly email: string 323 - readonly password: string 324 - } 325 - 326 - type LoginError = 327 - | { readonly _tag: "ValidationFailed"; readonly errors: Partial<Record<keyof LoginFormValues, string>> } 328 - | { readonly _tag: "SubmissionFailed"; readonly message: string } 329 - 330 - const LoginError = { 331 - validationFailed: (errors: Partial<Record<keyof LoginFormValues, string>>): LoginError => 332 - ({ _tag: "ValidationFailed", errors }), 333 - submissionFailed: (message: string): LoginError => 334 - ({ _tag: "SubmissionFailed", message }), 335 - } 336 - 337 - /** 338 - * Email validation rule 339 - */ 340 - const emailRule: ValidationRule<LoginFormValues, "email"> = (email) => 341 - email.includes("@") ? none : some("Invalid email address") 342 - 343 - /** 344 - * Password validation rule 345 - */ 346 - const passwordRule: ValidationRule<LoginFormValues, "password"> = (password) => 347 - password.length >= 8 ? none : some("Password must be at least 8 characters") 348 - 349 - /** 350 - * Create a login form controller 351 - * 352 - * This demonstrates how purus makes form handling cleaner: 353 - * 1. Typestate ensures you can't submit invalid forms 354 - * 2. Event cleanup is automatic when form is abandoned 355 - * 3. Race between submit/cancel is trivial 356 - */ 357 - const createLoginForm = ( 358 - emailInput: HTMLInputElement, 359 - passwordInput: HTMLInputElement, 360 - submitButton: HTMLButtonElement, 361 - cancelButton: HTMLButtonElement 362 - ): Eff<LoginFormValues, LoginError, unknown> => { 363 - // Initial form state 364 - const initialForm = emptyForm<LoginFormValues>({ 365 - email: "", 366 - password: "", 367 - }) 368 - 369 - /** 370 - * Watch for input changes 371 - * 372 - * Vanilla: Multiple event listeners, manual state management 373 - * Purus: Fork fibers for each input, collect updates 374 - */ 375 - const watchInputs = ( 376 - form: EmptyForm<LoginFormValues> | InvalidForm<LoginFormValues> 377 - ): Eff<ValidForm<LoginFormValues>, LoginError, unknown> => 378 - pipe( 379 - // Race: submit button click OR cancel 380 - race( 381 - // Wait for submit click 382 - pipe( 383 - onClick(submitButton), 384 - mapEff(() => { 385 - // Read current values from DOM 386 - const values: LoginFormValues = { 387 - email: emailInput.value, 388 - password: passwordInput.value, 389 - } 390 - // Update form with current values 391 - const updatedForm = entity<FormData<LoginFormValues>, Empty | Invalid>({ 392 - ...form, 393 - values, 394 - }) as EmptyForm<LoginFormValues> 395 - 396 - // Validate 397 - return validate(updatedForm, { 398 - email: emailRule, 399 - password: passwordRule, 400 - }) 401 - }) 402 - ), 403 - 404 - // Cancel button ends the form 405 - pipe( 406 - onClick(cancelButton), 407 - flatMap(() => fail(LoginError.submissionFailed("Cancelled by user"))) 408 - ) 409 - ), 410 - 411 - // Handle validation result 412 - flatMap(validatedForm => { 413 - if (validatedForm.errors && Object.keys(validatedForm.errors).length > 0) { 414 - // Show errors and try again 415 - console.log(" Validation failed:", validatedForm.errors) 416 - return watchInputs(validatedForm as InvalidForm<LoginFormValues>) 417 - } 418 - return succeed(validatedForm as ValidForm<LoginFormValues>) 419 - }) 420 - ) 421 - 422 - /** 423 - * Submit the form 424 - */ 425 - const submitForm = (form: ValidForm<LoginFormValues>): Eff<LoginFormValues, LoginError, unknown> => 426 - pipe( 427 - sync(() => console.log(" Submitting form...")), 428 - flatMap(() => sleep(1000)), // Simulate API call 429 - flatMap(() => { 430 - // Simulate success 431 - console.log(" Form submitted successfully!") 432 - return succeed(form.values) 433 - }) 434 - ) 435 - 436 - // Full workflow 437 - return pipe( 438 - watchInputs(initialForm), 439 - flatMap(validForm => submitForm(validForm)) 440 - ) 441 - } 442 - 443 - // ============================================================================= 444 - // SUBSCRIPTION PATTERNS 445 - // ============================================================================= 446 - 447 - /** 448 - * Subscribe to continuous events until cancelled 449 - * 450 - * This pattern is impossible to get right in vanilla JS without leaks. 451 - * With purus, cancellation propagates automatically. 452 - */ 453 - const subscribe = <A, E, R>( 454 - getEvent: () => Eff<A, E, R>, 455 - onEvent: (a: A) => void 456 - ): Eff<never, E, R> => { 457 - const loop = (): Eff<never, E, R> => 458 - pipe( 459 - getEvent(), 460 - flatMap(a => { 461 - onEvent(a) 462 - return loop() 463 - }) 464 - ) as Eff<never, E, R> 465 - 466 - return loop() 467 - } 468 - 469 - /** 470 - * Mouse position tracker with automatic cleanup 471 - * 472 - * Vanilla problem: 473 - * ```ts 474 - * const handler = (e) => { position = { x: e.clientX, y: e.clientY } } 475 - * document.addEventListener('mousemove', handler) 476 - * // When to remove? How to coordinate with component lifecycle? 477 - * ``` 478 - * 479 - * Purus solution: 480 - * Fork a fiber, interrupt it when done. Cleanup is automatic. 481 - */ 482 - type Position = { readonly x: number; readonly y: number } 483 - 484 - const trackMousePosition = ( 485 - target: EventTarget, 486 - onMove: (pos: Position) => void 487 - ): Eff<Fiber<never, never>, never, unknown> => 488 - fork( 489 - subscribe( 490 - () => fromEvent<MouseEvent>(target, "mousemove"), 491 - (e) => onMove({ x: e.clientX, y: e.clientY }) 492 - ) 493 - ) 494 - 495 - /** 496 - * Debounced search input 497 - * 498 - * Fires search after user stops typing for 300ms 499 - */ 500 - const debouncedSearch = ( 501 - input: HTMLInputElement, 502 - onSearch: (query: string) => void 503 - ): Eff<Fiber<never, never>, never, unknown> => 504 - fork( 505 - subscribe( 506 - () => 507 - pipe( 508 - onInput(input), 509 - debounce(300) 510 - ), 511 - (e) => onSearch((e.target as HTMLInputElement).value) 512 - ) 513 - ) 514 - 515 - // ============================================================================= 516 - // KEYBOARD SHORTCUTS WITH RACE 517 - // ============================================================================= 518 - 519 - /** 520 - * Wait for a specific key combination 521 - */ 522 - const waitForKey = ( 523 - target: EventTarget, 524 - key: string, 525 - modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean } = {} 526 - ): Eff<KeyboardEvent, never, unknown> => 527 - pipe( 528 - onKeyDown(target), 529 - flatMap(e => { 530 - const matches = 531 - e.key === key && 532 - (!modifiers.ctrl || e.ctrlKey) && 533 - (!modifiers.alt || e.altKey) && 534 - (!modifiers.shift || e.shiftKey) 535 - 536 - return matches 537 - ? succeed(e) 538 - : waitForKey(target, key, modifiers) 539 - }) 540 - ) 541 - 542 - /** 543 - * Race between multiple keyboard shortcuts 544 - * 545 - * First shortcut to be pressed wins, others are cleaned up. 546 - */ 547 - const waitForShortcut = ( 548 - target: EventTarget, 549 - shortcuts: ReadonlyArray<{ key: string; ctrl?: boolean; alt?: boolean; name: string }> 550 - ): Eff<string, never, unknown> => { 551 - if (shortcuts.length === 0) { 552 - return pipe( 553 - onKeyDown(target), 554 - flatMap(() => waitForShortcut(target, shortcuts)) 555 - ) 556 - } 557 - 558 - const waiters = shortcuts.map(s => 559 - pipe( 560 - waitForKey(target, s.key, { ctrl: s.ctrl, alt: s.alt }), 561 - mapEff(() => s.name) 562 - ) 563 - ) 564 - 565 - return waiters.reduce((a, b) => race(a, b)) 566 - } 567 - 568 - // ============================================================================= 569 - // DEMO - Simulated DOM environment 570 - // ============================================================================= 571 - 572 - // Check if we're in a browser environment 573 - const isBrowser = typeof window !== "undefined" && typeof MouseEvent !== "undefined" 574 - 575 - /** 576 - * Mock Event class for non-browser environments 577 - * Allows the demo to run in Node.js/Bun without real DOM APIs 578 - */ 579 - class MockEvent { 580 - readonly type: string 581 - readonly target: unknown 582 - constructor(type: string, options?: { target?: unknown }) { 583 - this.type = type 584 - this.target = options?.target 585 - } 586 - } 587 - 588 - /** 589 - * Mock MouseEvent class for non-browser environments 590 - */ 591 - class MockMouseEvent extends MockEvent { 592 - readonly clientX: number = 0 593 - readonly clientY: number = 0 594 - constructor(type: string) { 595 - super(type) 596 - } 597 - } 598 - 599 - // Use native Event/MouseEvent in browser, mock versions otherwise 600 - const EventClass = isBrowser ? Event : MockEvent 601 - const MouseEventClass = isBrowser ? MouseEvent : MockMouseEvent 602 - 603 - /** 604 - * Mock DOM elements for Node.js environment 605 - * In a browser, you'd use real DOM elements 606 - */ 607 - const createMockElement = (name: string): EventTarget => { 608 - const listeners = new Map<string, Set<EventListener>>() 609 - 610 - return { 611 - addEventListener(type: string, listener: EventListener) { 612 - if (!listeners.has(type)) listeners.set(type, new Set()) 613 - listeners.get(type)!.add(listener) 614 - console.log(` [${name}] Added listener for '${type}'`) 615 - }, 616 - removeEventListener(type: string, listener: EventListener) { 617 - listeners.get(type)?.delete(listener) 618 - console.log(` [${name}] Removed listener for '${type}'`) 619 - }, 620 - dispatchEvent(event: Event | MockEvent): boolean { 621 - listeners.get(event.type)?.forEach(l => l(event as Event)) 622 - return true 623 - }, 624 - } 625 - } 626 - 627 - const demo = async () => { 628 - console.log("╔═══════════════════════════════════════════════════════════════╗") 629 - console.log("║ REACTIVE DOM DEMO - purus-ts ║") 630 - console.log("╚═══════════════════════════════════════════════════════════════╝\n") 631 - 632 - if (!isBrowser) { 633 - console.log("Note: Running in non-browser environment (Node.js/Bun)") 634 - console.log(" Using mock Event classes for demonstration") 635 - console.log(" For real DOM integration, run in a browser with a bundler\n") 636 - } 637 - 638 - // 1. Event subscription with automatic cleanup 639 - console.log("─── 1. Event Subscription with Cleanup ───") 640 - const button = createMockElement("Button") 641 - 642 - const clickFiber = runFiber( 643 - pipe( 644 - fork( 645 - subscribe( 646 - () => onClick(button), 647 - () => console.log(" Button clicked!") 648 - ) 649 - ), 650 - flatMap(fiber => 651 - pipe( 652 - sleep(100), 653 - flatMap(() => { 654 - console.log(" Interrupting subscription...") 655 - return interruptFiber(fiber) 656 - }) 657 - ) 658 - ) 659 - ), 660 - undefined 661 - ) 662 - 663 - // Simulate some clicks 664 - setTimeout(() => button.dispatchEvent(new MouseEventClass("click")), 20) 665 - setTimeout(() => button.dispatchEvent(new MouseEventClass("click")), 50) 666 - 667 - await clickFiber.await() 668 - console.log(" ✓ Subscription interrupted, listeners cleaned up\n") 669 - 670 - // 2. Debounced input 671 - console.log("─── 2. Debounced Input ───") 672 - const input = createMockElement("SearchInput") 673 - 674 - let searchCount = 0 675 - const searchFiber = runFiber( 676 - debouncedSearch(input as HTMLInputElement, query => { 677 - searchCount++ 678 - console.log(` Search #${searchCount}: "${query}"`) 679 - }), 680 - undefined 681 - ) 682 - 683 - // Simulate rapid typing - only last one should fire 684 - const simulateTyping = (delay: number, value: string) => { 685 - setTimeout(() => { 686 - const event = new EventClass("input", { target: { value } }) as unknown as InputEvent 687 - input.dispatchEvent(event) 688 - }, delay) 689 - } 690 - 691 - simulateTyping(10, "h") 692 - simulateTyping(20, "he") 693 - simulateTyping(30, "hel") 694 - simulateTyping(40, "hello") // Only this should trigger search after debounce 695 - 696 - await runPromise(sleep(500)) 697 - 698 - // Stop the search subscription 699 - // searchFiber is Fiber<Fiber<never, never>, never> - the outer fiber's result is the inner fiber 700 - const innerFiber = await runPromise(searchFiber.join()) 701 - // Don't await the interrupt - the fiber is blocked on an async event listener that can't be cancelled 702 - innerFiber.interrupt() 703 - console.log(" ✓ Debounced search completed\n") 704 - 705 - // 3. Race between actions 706 - console.log("─── 3. Race Between Actions ───") 707 - const saveBtn = createMockElement("SaveButton") 708 - const cancelBtn = createMockElement("CancelButton") 709 - 710 - const raceEffect = race( 711 - pipe( 712 - onClick(saveBtn), 713 - mapEff(() => "saved" as const) 714 - ), 715 - pipe( 716 - onClick(cancelBtn), 717 - mapEff(() => "cancelled" as const) 718 - ) 719 - ) 720 - 721 - const raceFiber = runFiber(raceEffect, undefined) 722 - 723 - // Simulate cancel being clicked first 724 - setTimeout(() => { 725 - cancelBtn.dispatchEvent(new MouseEventClass("click")) 726 - console.log(" Cancel clicked first!") 727 - }, 50) 728 - 729 - const raceResult = await raceFiber.await() 730 - if (raceResult._tag === "Success") { 731 - console.log(` ✓ Race result: ${raceResult.value}`) 732 - console.log(" ✓ Other listener was automatically cleaned up\n") 733 - } 734 - 735 - // 4. Typestate form validation 736 - console.log("─── 4. Typestate Form Validation ───") 737 - 738 - const form = emptyForm<LoginFormValues>({ email: "", password: "" }) 739 - console.log(" Created empty form") 740 - 741 - // Update with invalid values 742 - const invalidAttempt = validate( 743 - { ...form, values: { email: "notanemail", password: "short" } } as EmptyForm<LoginFormValues>, 744 - { email: emailRule, password: passwordRule } 745 - ) 746 - console.log(" Validation result:", invalidAttempt.errors) 747 - 748 - // Update with valid values 749 - const validForm = validate( 750 - { ...form, values: { email: "user@example.com", password: "securepassword123" } } as EmptyForm<LoginFormValues>, 751 - { email: emailRule, password: passwordRule } 752 - ) 753 - console.log(" Valid form:", Object.keys(validForm.errors || {}).length === 0 ? "✓" : "✗") 754 - 755 - // Demonstrate typestate - this would be a compile error: 756 - // toSubmitting<LoginFormValues>()(invalidAttempt) // Error: Type 'InvalidForm' not assignable to 'ValidForm' 757 - 758 - const submitting = toSubmitting<LoginFormValues>()(validForm as ValidForm<LoginFormValues>) 759 - const submitted = toSubmitted<LoginFormValues>()(submitting) 760 - console.log(" Form state transitions: Empty → Valid → Submitting → Submitted ✓\n") 761 - 762 - console.log("═══════════════════════════════════════════════════════════════") 763 - console.log("Demo complete!") 764 - } 765 - 766 - demo() 767 - .catch(console.error) 768 - .finally(() => process.exit(0))
-576
examples/task-queue.ts
··· 1 - /** 2 - * Background Job Queue with Fiber Supervision 3 - * 4 - * Showcases: Fork/join, fiber supervision, retry, timeout, dependency injection, graceful shutdown 5 - * 6 - * ## Why purus is better than vanilla JS/TS: 7 - * 8 - * 1. **Workers are fibers - supervision is built-in** 9 - * - Vanilla: Manual worker management, restart logic, crash handling 10 - * - Purus: Fork workers, join to wait, interrupt to stop - all composable 11 - * 12 - * 2. **Timeout/retry per job without boilerplate** 13 - * - Vanilla: Each job needs its own try/catch, timeout handling, retry counter 14 - * - Purus: `pipe(job, timeout(30000), retry(3))` - one line 15 - * 16 - * 3. **Shutdown interrupts cleanly** 17 - * - Vanilla: Track all jobs, signal each, wait for completion, handle races 18 - * - Purus: Interrupt the worker pool → all jobs interrupted → cleanup runs 19 - * 20 - * 4. **Typed job payloads with exhaustive handling** 21 - * - Vanilla: Union types with `switch`, easy to forget cases 22 - * - Purus: `match()` on job type → compiler enforces all cases handled 23 - * 24 - * 5. **Dead letter queue is just effect composition** 25 - * - Vanilla: Separate error handling logic, manual DLQ push 26 - * - Purus: `catchAll(error => pushToDeadLetter(job, error))` 27 - * 28 - * 6. **Dependency injection for testing** 29 - * - Vanilla: Global state or constructor injection 30 - * - Purus: `provide(mockServices)(queue)` - swap implementations trivially 31 - */ 32 - 33 - import { 34 - // Types 35 - type Eff, 36 - type Exit, 37 - type Fiber, 38 - type FiberStatus, 39 - type Branded, 40 - type Result, 41 - 42 - // Constructors 43 - succeed, 44 - fail, 45 - async, 46 - sync, 47 - ok, 48 - err, 49 - 50 - // Transformations 51 - mapEff, 52 - flatMap, 53 - foldEff, 54 - catchAll, 55 - access, 56 - accessEff, 57 - provide, 58 - 59 - // Concurrency 60 - fork, 61 - join, 62 - race, 63 - all, 64 - interruptFiber, 65 - 66 - // Combinators 67 - sleep, 68 - timeout, 69 - retry, 70 - tapEff, 71 - 72 - // Composition 73 - pipe, 74 - match, 75 - brand, 76 - 77 - // Runners 78 - runFiber, 79 - runPromise, 80 - runPromiseExit, 81 - } from "../src/index" 82 - 83 - // ============================================================================= 84 - // DOMAIN TYPES 85 - // ============================================================================= 86 - 87 - type JobId = Branded<string, "JobId"> 88 - const JobId = (): JobId => brand(`job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) 89 - 90 - type QueueId = Branded<string, "QueueId"> 91 - const QueueId = (): QueueId => brand(`queue-${Date.now()}`) 92 - 93 - // ============================================================================= 94 - // JOB DEFINITIONS - Discriminated union of all job types 95 - // ============================================================================= 96 - 97 - /** 98 - * Typed job payloads 99 - * 100 - * Each job type has a specific payload and processing logic. 101 - * Pattern matching ensures all job types are handled. 102 - */ 103 - type Job = 104 - | { readonly _tag: "SendEmail"; readonly id: JobId; readonly to: string; readonly subject: string; readonly body: string } 105 - | { readonly _tag: "ProcessImage"; readonly id: JobId; readonly imageUrl: string; readonly operations: readonly string[] } 106 - | { readonly _tag: "GenerateReport"; readonly id: JobId; readonly reportType: string; readonly params: Record<string, unknown> } 107 - | { readonly _tag: "SyncData"; readonly id: JobId; readonly source: string; readonly destination: string } 108 - | { readonly _tag: "Cleanup"; readonly id: JobId; readonly olderThanDays: number } 109 - 110 - const Job = { 111 - sendEmail: (to: string, subject: string, body: string): Job => 112 - ({ _tag: "SendEmail", id: JobId(), to, subject, body }), 113 - 114 - processImage: (imageUrl: string, operations: readonly string[]): Job => 115 - ({ _tag: "ProcessImage", id: JobId(), imageUrl, operations }), 116 - 117 - generateReport: (reportType: string, params: Record<string, unknown>): Job => 118 - ({ _tag: "GenerateReport", id: JobId(), reportType, params }), 119 - 120 - syncData: (source: string, destination: string): Job => 121 - ({ _tag: "SyncData", id: JobId(), source, destination }), 122 - 123 - cleanup: (olderThanDays: number): Job => 124 - ({ _tag: "Cleanup", id: JobId(), olderThanDays }), 125 - } 126 - 127 - // ============================================================================= 128 - // JOB ERRORS 129 - // ============================================================================= 130 - 131 - type JobError = 132 - | { readonly _tag: "ProcessingFailed"; readonly jobId: JobId; readonly reason: string } 133 - | { readonly _tag: "Timeout"; readonly jobId: JobId; readonly timeoutMs: number } 134 - | { readonly _tag: "MaxRetriesExceeded"; readonly jobId: JobId; readonly attempts: number } 135 - | { readonly _tag: "ValidationFailed"; readonly jobId: JobId; readonly message: string } 136 - 137 - const JobError = { 138 - processingFailed: (jobId: JobId, reason: string): JobError => 139 - ({ _tag: "ProcessingFailed", jobId, reason }), 140 - timeout: (jobId: JobId, timeoutMs: number): JobError => 141 - ({ _tag: "Timeout", jobId, timeoutMs }), 142 - maxRetries: (jobId: JobId, attempts: number): JobError => 143 - ({ _tag: "MaxRetriesExceeded", jobId, attempts }), 144 - validation: (jobId: JobId, message: string): JobError => 145 - ({ _tag: "ValidationFailed", jobId, message }), 146 - } 147 - 148 - // ============================================================================= 149 - // SERVICE INTERFACES 150 - // ============================================================================= 151 - 152 - type EmailService = { 153 - readonly send: (to: string, subject: string, body: string) => Eff<void, string, unknown> 154 - } 155 - 156 - type ImageService = { 157 - readonly process: (url: string, operations: readonly string[]) => Eff<string, string, unknown> 158 - } 159 - 160 - type ReportService = { 161 - readonly generate: (type: string, params: Record<string, unknown>) => Eff<string, string, unknown> 162 - } 163 - 164 - type DataService = { 165 - readonly sync: (source: string, destination: string) => Eff<number, string, unknown> 166 - } 167 - 168 - type CleanupService = { 169 - readonly deleteOld: (olderThanDays: number) => Eff<number, string, unknown> 170 - } 171 - 172 - type DeadLetterQueue = { 173 - readonly push: (job: Job, error: JobError) => Eff<void, never, unknown> 174 - readonly getAll: () => Eff<readonly { job: Job; error: JobError }[], never, unknown> 175 - } 176 - 177 - type Services = { 178 - readonly email: EmailService 179 - readonly image: ImageService 180 - readonly report: ReportService 181 - readonly data: DataService 182 - readonly cleanup: CleanupService 183 - readonly deadLetter: DeadLetterQueue 184 - } 185 - 186 - // ============================================================================= 187 - // JOB PROCESSOR - Pattern matching on job types 188 - // ============================================================================= 189 - 190 - /** 191 - * Process a single job 192 - * 193 - * This is where typed pattern matching shines - the compiler 194 - * ensures we handle ALL job types. Add a new job type and 195 - * the code won't compile until we add a handler. 196 - */ 197 - const processJob = (job: Job): Eff<void, JobError, Services> => 198 - accessEff<Services, void, JobError>(services => { 199 - const process = match(job)({ 200 - SendEmail: ({ id, to, subject, body }) => 201 - pipe( 202 - sync(() => console.log(` [${id}] Sending email to ${to}`)), 203 - flatMap(() => services.email.send(to, subject, body)), 204 - mapEff(() => console.log(` [${id}] Email sent`)), 205 - catchAll(reason => fail(JobError.processingFailed(id, reason))) 206 - ), 207 - 208 - ProcessImage: ({ id, imageUrl, operations }) => 209 - pipe( 210 - sync(() => console.log(` [${id}] Processing image: ${imageUrl}`)), 211 - flatMap(() => services.image.process(imageUrl, operations)), 212 - mapEff(result => console.log(` [${id}] Image processed: ${result}`)), 213 - catchAll(reason => fail(JobError.processingFailed(id, reason))) 214 - ), 215 - 216 - GenerateReport: ({ id, reportType, params }) => 217 - pipe( 218 - sync(() => console.log(` [${id}] Generating ${reportType} report`)), 219 - flatMap(() => services.report.generate(reportType, params)), 220 - mapEff(result => console.log(` [${id}] Report generated: ${result}`)), 221 - catchAll(reason => fail(JobError.processingFailed(id, reason))) 222 - ), 223 - 224 - SyncData: ({ id, source, destination }) => 225 - pipe( 226 - sync(() => console.log(` [${id}] Syncing ${source} → ${destination}`)), 227 - flatMap(() => services.data.sync(source, destination)), 228 - mapEff(count => console.log(` [${id}] Synced ${count} records`)), 229 - catchAll(reason => fail(JobError.processingFailed(id, reason))) 230 - ), 231 - 232 - Cleanup: ({ id, olderThanDays }) => 233 - pipe( 234 - sync(() => console.log(` [${id}] Cleaning up data older than ${olderThanDays} days`)), 235 - flatMap(() => services.cleanup.deleteOld(olderThanDays)), 236 - mapEff(count => console.log(` [${id}] Cleaned up ${count} records`)), 237 - catchAll(reason => fail(JobError.processingFailed(id, reason))) 238 - ), 239 - }) 240 - 241 - return process as Eff<void, JobError, unknown> 242 - }) 243 - 244 - /** 245 - * Process job with timeout and retry 246 - */ 247 - const processJobWithRetry = ( 248 - job: Job, 249 - options: { timeoutMs?: number; maxRetries?: number } = {} 250 - ): Eff<void, JobError, Services> => { 251 - const timeoutMs = options.timeoutMs ?? 30000 252 - const maxRetries = options.maxRetries ?? 3 253 - 254 - return pipe( 255 - processJob(job), 256 - timeout(timeoutMs), 257 - flatMap(result => 258 - result === null 259 - ? fail(JobError.timeout(job.id, timeoutMs)) 260 - : succeed(result) 261 - ), 262 - retry(maxRetries), 263 - catchAll(error => { 264 - // After all retries failed, push to dead letter queue 265 - return accessEff<Services, void, JobError>(({ deadLetter }) => 266 - pipe( 267 - deadLetter.push(job, error), 268 - flatMap(() => fail(error)) 269 - ) 270 - ) 271 - }) 272 - ) 273 - } 274 - 275 - // ============================================================================= 276 - // WORKER POOL 277 - // ============================================================================= 278 - 279 - type WorkerPool = { 280 - readonly id: QueueId 281 - readonly submit: (job: Job) => Eff<void, never, unknown> 282 - readonly getStats: () => WorkerStats 283 - readonly shutdown: () => Eff<void, never, unknown> 284 - } 285 - 286 - type WorkerStats = { 287 - readonly queued: number 288 - readonly processing: number 289 - readonly completed: number 290 - readonly failed: number 291 - } 292 - 293 - /** 294 - * Create a worker pool with configurable concurrency 295 - * 296 - * Vanilla problem: 297 - * ```ts 298 - * class WorkerPool { 299 - * private workers: Worker[] = [] 300 - * private queue: Job[] = [] 301 - * private processing = 0 302 - * 303 - * async submit(job: Job) { 304 - * this.queue.push(job) 305 - * await this.processNext() 306 - * } 307 - * 308 - * async shutdown() { 309 - * // Complex - need to: 310 - * // 1. Stop accepting new jobs 311 - * // 2. Wait for in-flight jobs 312 - * // 3. Cancel remaining queued jobs 313 - * // 4. Handle race conditions 314 - * } 315 - * } 316 - * ``` 317 - * 318 - * Purus solution: 319 - * Workers are fibers. Submit adds to queue. 320 - * Shutdown interrupts all workers → cleanup runs automatically. 321 - */ 322 - const createWorkerPool = ( 323 - workerCount: number, 324 - services: Services 325 - ): Eff<WorkerPool, never, unknown> => { 326 - const id = QueueId() 327 - const queue: Job[] = [] 328 - const stats = { queued: 0, processing: 0, completed: 0, failed: 0 } 329 - let running = true 330 - const workers: Fiber<void, never>[] = [] 331 - 332 - const worker = (workerId: number): Eff<void, never, unknown> => { 333 - const processNext = (): Eff<void, never, unknown> => { 334 - if (!running) { 335 - return succeed(undefined) 336 - } 337 - 338 - const job = queue.shift() 339 - if (!job) { 340 - // No jobs, wait and check again 341 - return pipe( 342 - sleep(100), 343 - flatMap(() => processNext()) 344 - ) 345 - } 346 - 347 - stats.queued-- 348 - stats.processing++ 349 - console.log(` [Worker ${workerId}] Processing ${job._tag} (${job.id})`) 350 - 351 - return pipe( 352 - processJobWithRetry(job), 353 - provide(services), 354 - foldEff( 355 - error => { 356 - stats.processing-- 357 - stats.failed++ 358 - console.log(` [Worker ${workerId}] Failed: ${job.id} - ${error._tag}`) 359 - return succeed(undefined) 360 - }, 361 - () => { 362 - stats.processing-- 363 - stats.completed++ 364 - console.log(` [Worker ${workerId}] Completed: ${job.id}`) 365 - return succeed(undefined) 366 - } 367 - ), 368 - flatMap(() => processNext()) 369 - ) as Eff<void, never, unknown> 370 - } 371 - 372 - return processNext() 373 - } 374 - 375 - // Fork all workers 376 - const startWorkers = (): Eff<void, never, unknown> => 377 - pipe( 378 - all(Array.from({ length: workerCount }, (_, i) => fork(worker(i + 1)))), 379 - mapEff(fibers => { 380 - workers.push(...fibers) 381 - }) 382 - ) as Eff<void, never, unknown> 383 - 384 - return pipe( 385 - startWorkers(), 386 - mapEff(() => ({ 387 - id, 388 - 389 - submit: (job: Job): Eff<void, never, unknown> => 390 - sync(() => { 391 - queue.push(job) 392 - stats.queued++ 393 - console.log(` [Queue] Job submitted: ${job._tag} (${job.id})`) 394 - }), 395 - 396 - getStats: () => ({ ...stats }), 397 - 398 - shutdown: (): Eff<void, never, unknown> => { 399 - running = false 400 - console.log(` [Queue ${id}] Shutting down ${workers.length} workers...`) 401 - 402 - return pipe( 403 - all(workers.map(w => interruptFiber(w))), 404 - mapEff(() => { 405 - console.log(` [Queue ${id}] All workers stopped`) 406 - }) 407 - ) as Eff<void, never, unknown> 408 - }, 409 - })) 410 - ) 411 - } 412 - 413 - // ============================================================================= 414 - // MOCK SERVICES 415 - // ============================================================================= 416 - 417 - const createMockServices = (): Services => { 418 - const deadLetterItems: { job: Job; error: JobError }[] = [] 419 - 420 - return { 421 - email: { 422 - send: (to, subject, _body) => 423 - pipe( 424 - sleep(500), // Simulate network delay 425 - flatMap(() => 426 - Math.random() > 0.1 427 - ? succeed(undefined) 428 - : fail(`Failed to send email to ${to}`) 429 - ) 430 - ), 431 - }, 432 - 433 - image: { 434 - process: (url, operations) => 435 - pipe( 436 - sleep(800), 437 - mapEff(() => `processed-${operations.join("-")}-${url.split("/").pop()}`) 438 - ), 439 - }, 440 - 441 - report: { 442 - generate: (type, params) => 443 - pipe( 444 - sleep(1000), 445 - mapEff(() => `report-${type}-${Date.now()}.pdf`) 446 - ), 447 - }, 448 - 449 - data: { 450 - sync: (source, destination) => 451 - pipe( 452 - sleep(600), 453 - mapEff(() => Math.floor(Math.random() * 1000)) 454 - ), 455 - }, 456 - 457 - cleanup: { 458 - deleteOld: (days) => 459 - pipe( 460 - sleep(400), 461 - mapEff(() => Math.floor(Math.random() * 100)) 462 - ), 463 - }, 464 - 465 - deadLetter: { 466 - push: (job, error) => 467 - sync(() => { 468 - deadLetterItems.push({ job, error }) 469 - console.log(` [DLQ] Job ${job.id} added: ${error._tag}`) 470 - }), 471 - 472 - getAll: () => 473 - succeed(deadLetterItems), 474 - }, 475 - } 476 - } 477 - 478 - // ============================================================================= 479 - // DEMO 480 - // ============================================================================= 481 - 482 - const demo = async () => { 483 - console.log("╔═══════════════════════════════════════════════════════════════╗") 484 - console.log("║ TASK QUEUE DEMO - purus-ts ║") 485 - console.log("╚═══════════════════════════════════════════════════════════════╝\n") 486 - 487 - const services = createMockServices() 488 - 489 - // 1. Create worker pool 490 - console.log("─── 1. Create Worker Pool ───") 491 - const pool = await runPromise(createWorkerPool(3, services)) 492 - console.log(` ✓ Created worker pool ${pool.id} with 3 workers\n`) 493 - 494 - // 2. Submit jobs 495 - console.log("─── 2. Submit Jobs ───") 496 - 497 - const jobs = [ 498 - Job.sendEmail("user@example.com", "Welcome!", "Hello and welcome..."), 499 - Job.processImage("https://example.com/photo.jpg", ["resize", "watermark"]), 500 - Job.generateReport("monthly", { month: 12, year: 2024 }), 501 - Job.syncData("postgres", "elasticsearch"), 502 - Job.cleanup(30), 503 - Job.sendEmail("admin@example.com", "Alert", "System notification"), 504 - ] 505 - 506 - for (const job of jobs) { 507 - await runPromise(pool.submit(job)) 508 - } 509 - 510 - console.log(` ✓ Submitted ${jobs.length} jobs\n`) 511 - 512 - // 3. Monitor progress 513 - console.log("─── 3. Processing... ───") 514 - 515 - // Wait for jobs to complete 516 - await new Promise(resolve => setTimeout(resolve, 4000)) 517 - 518 - const stats = pool.getStats() 519 - console.log(`\n Stats: queued=${stats.queued}, processing=${stats.processing}, completed=${stats.completed}, failed=${stats.failed}\n`) 520 - 521 - // 4. Check dead letter queue 522 - console.log("─── 4. Dead Letter Queue ───") 523 - const dlqItems = await runPromise(services.deadLetter.getAll()) 524 - if (dlqItems.length > 0) { 525 - console.log(` ${dlqItems.length} failed jobs in DLQ:`) 526 - dlqItems.forEach(({ job, error }) => { 527 - console.log(` - ${job._tag} (${job.id}): ${error._tag}`) 528 - }) 529 - } else { 530 - console.log(" ✓ No failed jobs") 531 - } 532 - console.log() 533 - 534 - // 5. Graceful shutdown 535 - console.log("─── 5. Graceful Shutdown ───") 536 - 537 - // Submit more jobs 538 - await runPromise(pool.submit(Job.processImage("https://example.com/big.jpg", ["optimize"]))) 539 - await runPromise(pool.submit(Job.generateReport("annual", { year: 2024 }))) 540 - 541 - // Shutdown immediately 542 - await runPromise(pool.shutdown()) 543 - 544 - const finalStats = pool.getStats() 545 - console.log(` Final stats: queued=${finalStats.queued}, completed=${finalStats.completed}, failed=${finalStats.failed}\n`) 546 - 547 - // 6. Error handling demo 548 - console.log("─── 6. Error Handling ───") 549 - 550 - const formatJobError = (error: JobError): string => 551 - match(error)({ 552 - ProcessingFailed: ({ jobId, reason }) => `Job ${jobId} failed: ${reason}`, 553 - Timeout: ({ jobId, timeoutMs }) => `Job ${jobId} timed out after ${timeoutMs}ms`, 554 - MaxRetriesExceeded: ({ jobId, attempts }) => `Job ${jobId} failed after ${attempts} attempts`, 555 - ValidationFailed: ({ jobId, message }) => `Job ${jobId} validation failed: ${message}`, 556 - }) 557 - 558 - const errorJob = Job.sendEmail("invalid", "Test", "Body") 559 - const errorResult = await runPromiseExit( 560 - pipe( 561 - processJobWithRetry(errorJob, { timeoutMs: 100, maxRetries: 1 }), 562 - provide(services) 563 - ) 564 - ) 565 - 566 - if (errorResult._tag === "Failure") { 567 - console.log(` ${formatJobError(errorResult.error as JobError)}\n`) 568 - } 569 - 570 - console.log("═══════════════════════════════════════════════════════════════") 571 - console.log("Demo complete!") 572 - } 573 - 574 - demo() 575 - .catch(console.error) 576 - .finally(() => process.exit(0))
-709
examples/workflow-engine.ts
··· 1 - /** 2 - * Order Processing Pipeline 3 - * 4 - * Showcases: Branded types, refinements, typestate, Result, dependency injection, pattern matching 5 - * 6 - * ## Why purus is better than vanilla JS/TS: 7 - * 8 - * 1. **Branded IDs prevent mixing up different entities** 9 - * - Vanilla: `function processOrder(userId: string, orderId: string)` - easy to swap args! 10 - * - Purus: `function processOrder(userId: UserId, orderId: OrderId)` - compiler catches swaps 11 - * 12 - * 2. **Refinements guarantee validated values at compile time** 13 - * - Vanilla: `if (quantity > 0)` checks scattered everywhere, easy to forget 14 - * - Purus: `PositiveQuantity` type - can't construct invalid values 15 - * 16 - * 3. **Typestate prevents invalid state transitions** 17 - * - Vanilla: Runtime checks like `if (order.status === 'paid') order.ship()` 18 - * - Purus: `ship(order: Order<Paid>)` - can't ship unpaid orders, compiler enforces 19 - * 20 - * 4. **Result type makes errors explicit and composable** 21 - * - Vanilla: Exceptions fly anywhere, `try/catch` scattered, error types unknown 22 - * - Purus: `Result<Order, OrderError>` - caller knows exactly what can fail 23 - * 24 - * 5. **Pattern matching forces exhaustive error handling** 25 - * - Vanilla: `switch` can forget cases, no compiler warning 26 - * - Purus: `match()` errors if you miss a case, add new error → compiler tells you where 27 - * 28 - * 6. **Dependency injection is just function composition** 29 - * - Vanilla: DI frameworks, decorators, reflection, constructor injection 30 - * - Purus: `provide(services)(effect)` - swap implementations trivially for tests 31 - */ 32 - 33 - import { 34 - // Types 35 - type Branded, 36 - type Refined, 37 - type Entity, 38 - type Option, 39 - type Result, 40 - type Eff, 41 - 42 - // Constructors 43 - brand, 44 - refine, 45 - entity, 46 - transition, 47 - some, 48 - none, 49 - ok, 50 - err, 51 - succeed, 52 - fail, 53 - sync, 54 - 55 - // Transformations 56 - mapResult, 57 - chainResult, 58 - mapOption, 59 - flatMapOption, 60 - getOrElse, 61 - mapEff, 62 - flatMap, 63 - catchAll, 64 - accessEff, 65 - provide, 66 - 67 - // Pattern matching 68 - match, 69 - matchResult, 70 - matchOption, 71 - 72 - // Composition 73 - pipe, 74 - 75 - // Runners 76 - runPromise, 77 - } from "../src/index" 78 - 79 - // ============================================================================= 80 - // BRANDED TYPES - Distinct IDs that can't be mixed up 81 - // ============================================================================= 82 - 83 - /** 84 - * The Problem with Plain Strings 85 - * 86 - * Vanilla TypeScript: 87 - * ```ts 88 - * function placeOrder(userId: string, productId: string, orderId: string) { ... } 89 - * 90 - * // This compiles! But it's wrong - arguments are swapped 91 - * placeOrder(orderId, userId, productId) 92 - * ``` 93 - * 94 - * With Branded Types: 95 - * ```ts 96 - * function placeOrder(userId: UserId, productId: ProductId, orderId: OrderId) { ... } 97 - * 98 - * // Compile error! Type 'OrderId' is not assignable to type 'UserId' 99 - * placeOrder(orderId, userId, productId) 100 - * ``` 101 - */ 102 - 103 - type UserId = Branded<string, "UserId"> 104 - const UserId = { 105 - create: (): UserId => brand(`user-${crypto.randomUUID().slice(0, 8)}`), 106 - fromString: (s: string): UserId => brand(s), 107 - } 108 - 109 - type ProductId = Branded<string, "ProductId"> 110 - const ProductId = { 111 - create: (): ProductId => brand(`prod-${crypto.randomUUID().slice(0, 8)}`), 112 - fromString: (s: string): ProductId => brand(s), 113 - } 114 - 115 - type OrderId = Branded<string, "OrderId"> 116 - const OrderId = { 117 - create: (): OrderId => brand(`order-${crypto.randomUUID().slice(0, 8)}`), 118 - fromString: (s: string): OrderId => brand(s), 119 - } 120 - 121 - // ============================================================================= 122 - // REFINEMENT TYPES - Values with proven properties 123 - // ============================================================================= 124 - 125 - /** 126 - * The Problem with Unchecked Numbers 127 - * 128 - * Vanilla TypeScript: 129 - * ```ts 130 - * function addToCart(productId: string, quantity: number) { 131 - * // Need to check quantity > 0 here... 132 - * // And here... 133 - * // And everywhere quantity is used... 134 - * } 135 - * ``` 136 - * 137 - * With Refinements: 138 - * ```ts 139 - * function addToCart(productId: ProductId, quantity: PositiveInt) { 140 - * // quantity is GUARANTEED to be positive by the type system 141 - * // No runtime check needed - it was validated at construction 142 - * } 143 - * ``` 144 - */ 145 - 146 - type PositiveInt = Refined<number, "PositiveInt"> 147 - const positiveInt = refine<number, "PositiveInt">(n => Number.isInteger(n) && n > 0) 148 - 149 - type NonNegativeAmount = Refined<number, "NonNegativeAmount"> 150 - const nonNegativeAmount = refine<number, "NonNegativeAmount">(n => n >= 0) 151 - 152 - type Email = Branded<string, "Email"> 153 - const Email = { 154 - create: (s: string): Option<Email> => 155 - s.includes("@") ? some(brand(s)) : none, 156 - unsafeCreate: (s: string): Email => brand(s), 157 - } 158 - 159 - // ============================================================================= 160 - // ORDER STATE MACHINE - Typestate pattern 161 - // ============================================================================= 162 - 163 - /** 164 - * The Problem with String Status Fields 165 - * 166 - * Vanilla TypeScript: 167 - * ```ts 168 - * type Order = { 169 - * status: "draft" | "pending" | "paid" | "shipped" | "delivered" 170 - * ... 171 - * } 172 - * 173 - * function shipOrder(order: Order) { 174 - * if (order.status !== "paid") { 175 - * throw new Error("Can't ship unpaid order") // Runtime error! 176 - * } 177 - * order.status = "shipped" 178 - * } 179 - * ``` 180 - * 181 - * With Typestate: 182 - * ```ts 183 - * type PaidOrder = Entity<OrderData, "Paid"> 184 - * type ShippedOrder = Entity<OrderData, "Shipped"> 185 - * 186 - * function shipOrder(order: PaidOrder): ShippedOrder { 187 - * // Can ONLY be called with a PaidOrder - enforced at compile time! 188 - * return transition<OrderData, "Paid", "Shipped">()(order) 189 - * } 190 - * 191 - * // Compile error: Argument of type 'DraftOrder' is not assignable 192 - * shipOrder(draftOrder) 193 - * ``` 194 - */ 195 - 196 - type OrderData = { 197 - readonly id: OrderId 198 - readonly userId: UserId 199 - readonly items: ReadonlyArray<OrderItem> 200 - readonly total: NonNegativeAmount 201 - readonly shippingAddress?: Address 202 - readonly paymentId?: string 203 - readonly trackingNumber?: string 204 - } 205 - 206 - type OrderItem = { 207 - readonly productId: ProductId 208 - readonly name: string 209 - readonly quantity: PositiveInt 210 - readonly price: NonNegativeAmount 211 - } 212 - 213 - type Address = { 214 - readonly street: string 215 - readonly city: string 216 - readonly zip: string 217 - readonly country: string 218 - } 219 - 220 - // Order states as phantom types 221 - type Draft = "Draft" 222 - type Pending = "Pending" 223 - type Paid = "Paid" 224 - type Shipped = "Shipped" 225 - type Delivered = "Delivered" 226 - type Cancelled = "Cancelled" 227 - 228 - type DraftOrder = Entity<OrderData, Draft> 229 - type PendingOrder = Entity<OrderData, Pending> 230 - type PaidOrder = Entity<OrderData, Paid> 231 - type ShippedOrder = Entity<OrderData, Shipped> 232 - type DeliveredOrder = Entity<OrderData, Delivered> 233 - type CancelledOrder = Entity<OrderData, Cancelled> 234 - 235 - // State transitions - each is a pure function with precise types 236 - const toDraft = (data: OrderData): DraftOrder => entity(data) 237 - const toPending = transition<OrderData, Draft, Pending>() 238 - const toPaid = (paymentId: string) => 239 - transition<OrderData, Pending, Paid>(order => ({ ...order, paymentId })) 240 - const toShipped = (trackingNumber: string) => 241 - transition<OrderData, Paid, Shipped>(order => ({ ...order, trackingNumber })) 242 - const toDelivered = transition<OrderData, Shipped, Delivered>() 243 - const toCancelled = <S extends Draft | Pending>() => 244 - transition<OrderData, S, Cancelled>() 245 - 246 - // ============================================================================= 247 - // TYPED BUSINESS ERRORS 248 - // ============================================================================= 249 - 250 - /** 251 - * The Problem with Generic Errors 252 - * 253 - * Vanilla TypeScript: 254 - * try { await placeOrder(...) } 255 - * catch (e) { // What is e? Could be ANYTHING } 256 - * 257 - * With Discriminated Union Errors: 258 - * const result: Result<Order, OrderError> = placeOrder(...) 259 - * match(result)({ 260 - * Ok: ({ value }) => handleSuccess(value), 261 - * Err: ({ error }) => match(error)({ 262 - * OutOfStock: ... , // Must handle 263 - * PaymentDeclined: ..., // Must handle 264 - * // Compiler error if you miss one! 265 - * }) 266 - * }) 267 - */ 268 - 269 - type OrderError = 270 - | { readonly _tag: "EmptyCart" } 271 - | { readonly _tag: "OutOfStock"; readonly productId: ProductId; readonly available: number } 272 - | { readonly _tag: "PaymentDeclined"; readonly reason: string } 273 - | { readonly _tag: "InvalidAddress"; readonly message: string } 274 - | { readonly _tag: "UserNotFound"; readonly userId: UserId } 275 - | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId } 276 - | { readonly _tag: "InvalidStateTransition"; readonly from: string; readonly to: string } 277 - 278 - const OrderError = { 279 - emptyCart: (): OrderError => ({ _tag: "EmptyCart" }), 280 - outOfStock: (productId: ProductId, available: number): OrderError => 281 - ({ _tag: "OutOfStock", productId, available }), 282 - paymentDeclined: (reason: string): OrderError => 283 - ({ _tag: "PaymentDeclined", reason }), 284 - invalidAddress: (message: string): OrderError => 285 - ({ _tag: "InvalidAddress", message }), 286 - userNotFound: (userId: UserId): OrderError => 287 - ({ _tag: "UserNotFound", userId }), 288 - orderNotFound: (orderId: OrderId): OrderError => 289 - ({ _tag: "OrderNotFound", orderId }), 290 - invalidTransition: (from: string, to: string): OrderError => 291 - ({ _tag: "InvalidStateTransition", from, to }), 292 - } 293 - 294 - // ============================================================================= 295 - // SERVICE INTERFACES - For dependency injection 296 - // ============================================================================= 297 - 298 - type User = { 299 - readonly id: UserId 300 - readonly email: Email 301 - readonly name: string 302 - } 303 - 304 - type Product = { 305 - readonly id: ProductId 306 - readonly name: string 307 - readonly price: NonNegativeAmount 308 - readonly stock: number 309 - } 310 - 311 - type UserService = { 312 - readonly getUser: (id: UserId) => Eff<User, OrderError, unknown> 313 - } 314 - 315 - type InventoryService = { 316 - readonly getProduct: (id: ProductId) => Eff<Product, OrderError, unknown> 317 - readonly reserveStock: (productId: ProductId, quantity: PositiveInt) => Eff<void, OrderError, unknown> 318 - readonly releaseStock: (productId: ProductId, quantity: PositiveInt) => Eff<void, never, unknown> 319 - } 320 - 321 - type PaymentService = { 322 - readonly processPayment: (userId: UserId, amount: NonNegativeAmount) => Eff<string, OrderError, unknown> 323 - readonly refundPayment: (paymentId: string) => Eff<void, never, unknown> 324 - } 325 - 326 - type ShippingService = { 327 - readonly validateAddress: (address: Address) => Eff<void, OrderError, unknown> 328 - readonly createShipment: (orderId: OrderId, address: Address) => Eff<string, OrderError, unknown> 329 - } 330 - 331 - type NotificationService = { 332 - readonly sendOrderConfirmation: (email: Email, orderId: OrderId) => Eff<void, never, unknown> 333 - readonly sendShippingNotification: (email: Email, trackingNumber: string) => Eff<void, never, unknown> 334 - } 335 - 336 - type Services = { 337 - readonly users: UserService 338 - readonly inventory: InventoryService 339 - readonly payments: PaymentService 340 - readonly shipping: ShippingService 341 - readonly notifications: NotificationService 342 - } 343 - 344 - // ============================================================================= 345 - // ORDER WORKFLOW - Pure functional business logic 346 - // ============================================================================= 347 - 348 - /** 349 - * Create a new draft order 350 - * 351 - * Note: This validates that items is non-empty at the type level! 352 - * If items were empty, we couldn't construct the DraftOrder. 353 - */ 354 - const createOrder = ( 355 - userId: UserId, 356 - items: ReadonlyArray<{ productId: ProductId; name: string; quantity: PositiveInt; price: NonNegativeAmount }> 357 - ): Result<DraftOrder, OrderError> => { 358 - if (items.length === 0) { 359 - return err(OrderError.emptyCart()) 360 - } 361 - 362 - const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0) 363 - 364 - return ok(toDraft({ 365 - id: OrderId.create(), 366 - userId, 367 - items: items as ReadonlyArray<OrderItem>, 368 - total: nonNegativeAmount(total) ?? (0 as NonNegativeAmount), 369 - })) 370 - } 371 - 372 - /** 373 - * Submit order for processing 374 - * 375 - * This is where Purus shines - the entire workflow is: 376 - * 1. Type-safe (can't mix up IDs, quantities are validated) 377 - * 2. Error-tracked (all failures are typed and must be handled) 378 - * 3. Dependency-injected (services are passed via environment) 379 - * 4. Composable (each step is a pure function) 380 - */ 381 - const submitOrder = ( 382 - order: DraftOrder, 383 - shippingAddress: Address 384 - ): Eff<PendingOrder, OrderError, Services> => 385 - accessEff<Services, PendingOrder, OrderError>(({ inventory, shipping }) => 386 - pipe( 387 - // Validate shipping address 388 - shipping.validateAddress(shippingAddress), 389 - 390 - // Reserve inventory for all items 391 - flatMap(() => 392 - order.items.reduce( 393 - (acc, item) => 394 - pipe( 395 - acc, 396 - flatMap(() => inventory.reserveStock(item.productId, item.quantity)) 397 - ), 398 - succeed(undefined) as Eff<void, OrderError, unknown> 399 - ) 400 - ), 401 - 402 - // Transition to pending state 403 - mapEff(() => { 404 - const withAddress = { ...order, shippingAddress } 405 - return toPending(withAddress as unknown as DraftOrder) 406 - }) 407 - ) 408 - ) 409 - 410 - /** 411 - * Process payment for a pending order 412 - */ 413 - const processPayment = (order: PendingOrder): Eff<PaidOrder, OrderError, Services> => 414 - accessEff<Services, PaidOrder, OrderError>(({ users, payments, notifications }) => 415 - pipe( 416 - // Get user for notification 417 - users.getUser(order.userId), 418 - 419 - // Process payment 420 - flatMap(user => 421 - pipe( 422 - payments.processPayment(order.userId, order.total), 423 - flatMap(paymentId => 424 - pipe( 425 - // Send confirmation email 426 - notifications.sendOrderConfirmation(user.email, order.id), 427 - // Transition to paid state 428 - mapEff(() => toPaid(paymentId)(order)) 429 - ) 430 - ) 431 - ) 432 - ) 433 - ) 434 - ) 435 - 436 - /** 437 - * Ship a paid order 438 - */ 439 - const shipOrder = (order: PaidOrder): Eff<ShippedOrder, OrderError, Services> => 440 - accessEff<Services, ShippedOrder, OrderError>(({ users, shipping, notifications }) => 441 - pipe( 442 - users.getUser(order.userId), 443 - flatMap(user => 444 - pipe( 445 - shipping.createShipment(order.id, order.shippingAddress!), 446 - flatMap(trackingNumber => 447 - pipe( 448 - notifications.sendShippingNotification(user.email, trackingNumber), 449 - mapEff(() => toShipped(trackingNumber)(order)) 450 - ) 451 - ) 452 - ) 453 - ) 454 - ) 455 - ) 456 - 457 - /** 458 - * Full order workflow - from cart to shipped 459 - * 460 - * Look how clean this is compared to the vanilla equivalent with 461 - * try/catch, if/else, and scattered validation! 462 - */ 463 - const placeOrder = ( 464 - userId: UserId, 465 - items: ReadonlyArray<{ productId: ProductId; name: string; quantity: PositiveInt; price: NonNegativeAmount }>, 466 - shippingAddress: Address 467 - ): Eff<ShippedOrder, OrderError, Services> => 468 - pipe( 469 - // Create draft order (synchronous, returns Result) 470 - sync(() => createOrder(userId, items)), 471 - 472 - // Unwrap Result to Eff 473 - flatMap(result => 474 - matchResult<DraftOrder, OrderError, Eff<DraftOrder, OrderError, unknown>>( 475 - order => succeed(order), 476 - error => fail(error) 477 - )(result) 478 - ), 479 - 480 - // Submit → Pay → Ship 481 - flatMap(draft => submitOrder(draft, shippingAddress)), 482 - flatMap(pending => processPayment(pending)), 483 - flatMap(paid => shipOrder(paid)) 484 - ) 485 - 486 - // ============================================================================= 487 - // ERROR HANDLING WITH EXHAUSTIVE PATTERN MATCHING 488 - // ============================================================================= 489 - 490 - /** 491 - * Format error for display 492 - * 493 - * If you add a new OrderError variant, the compiler will error here 494 - * until you add a handler for it. No forgotten edge cases! 495 - */ 496 - const formatOrderError = (error: OrderError): string => 497 - match(error)({ 498 - EmptyCart: () => "Cannot create an order with an empty cart", 499 - OutOfStock: ({ productId, available }) => 500 - `Product ${productId} is out of stock (only ${available} available)`, 501 - PaymentDeclined: ({ reason }) => `Payment was declined: ${reason}`, 502 - InvalidAddress: ({ message }) => `Invalid shipping address: ${message}`, 503 - UserNotFound: ({ userId }) => `User ${userId} not found`, 504 - OrderNotFound: ({ orderId }) => `Order ${orderId} not found`, 505 - InvalidStateTransition: ({ from, to }) => 506 - `Cannot transition order from ${from} to ${to}`, 507 - }) 508 - 509 - // ============================================================================= 510 - // DEMO - Mock services and run the workflow 511 - // ============================================================================= 512 - 513 - const createMockServices = (): Services => { 514 - // In-memory "database" 515 - const users = new Map<string, User>([ 516 - [ 517 - "user-1", 518 - { 519 - id: UserId.fromString("user-1"), 520 - email: Email.unsafeCreate("alice@example.com"), 521 - name: "Alice", 522 - }, 523 - ], 524 - ]) 525 - 526 - const products = new Map<string, Product>([ 527 - [ 528 - "prod-1", 529 - { 530 - id: ProductId.fromString("prod-1"), 531 - name: "Widget", 532 - price: nonNegativeAmount(29.99) ?? (0 as NonNegativeAmount), 533 - stock: 100, 534 - }, 535 - ], 536 - [ 537 - "prod-2", 538 - { 539 - id: ProductId.fromString("prod-2"), 540 - name: "Gadget", 541 - price: nonNegativeAmount(49.99) ?? (0 as NonNegativeAmount), 542 - stock: 5, 543 - }, 544 - ], 545 - ]) 546 - 547 - return { 548 - users: { 549 - getUser: (id) => { 550 - const user = users.get(id) 551 - return user ? succeed(user) : fail(OrderError.userNotFound(id)) 552 - }, 553 - }, 554 - 555 - inventory: { 556 - getProduct: (id) => { 557 - const product = products.get(id) 558 - return product 559 - ? succeed(product) 560 - : fail(OrderError.outOfStock(id, 0)) 561 - }, 562 - reserveStock: (productId, quantity) => { 563 - const product = products.get(productId) 564 - if (!product) return fail(OrderError.outOfStock(productId, 0)) 565 - if (product.stock < quantity) { 566 - return fail(OrderError.outOfStock(productId, product.stock)) 567 - } 568 - products.set(productId, { ...product, stock: product.stock - quantity }) 569 - console.log(` [Inventory] Reserved ${quantity}x ${product.name}`) 570 - return succeed(undefined) 571 - }, 572 - releaseStock: (productId, quantity) => { 573 - const product = products.get(productId) 574 - if (product) { 575 - products.set(productId, { ...product, stock: product.stock + quantity }) 576 - } 577 - return succeed(undefined) 578 - }, 579 - }, 580 - 581 - payments: { 582 - processPayment: (userId, amount) => { 583 - console.log(` [Payment] Processing $${amount} for ${userId}`) 584 - return succeed(`pay-${Date.now()}`) 585 - }, 586 - refundPayment: (paymentId) => { 587 - console.log(` [Payment] Refunded ${paymentId}`) 588 - return succeed(undefined) 589 - }, 590 - }, 591 - 592 - shipping: { 593 - validateAddress: (address) => { 594 - if (!address.zip || address.zip.length < 3) { 595 - return fail(OrderError.invalidAddress("Invalid ZIP code")) 596 - } 597 - console.log(` [Shipping] Address validated: ${address.city}`) 598 - return succeed(undefined) 599 - }, 600 - createShipment: (orderId, address) => { 601 - const tracking = `TRACK-${Date.now()}` 602 - console.log(` [Shipping] Created shipment ${tracking} for ${orderId}`) 603 - return succeed(tracking) 604 - }, 605 - }, 606 - 607 - notifications: { 608 - sendOrderConfirmation: (email, orderId) => { 609 - console.log(` [Email] Order confirmation sent to ${email}`) 610 - return succeed(undefined) 611 - }, 612 - sendShippingNotification: (email, trackingNumber) => { 613 - console.log(` [Email] Shipping notification sent: ${trackingNumber}`) 614 - return succeed(undefined) 615 - }, 616 - }, 617 - } 618 - } 619 - 620 - const demo = async () => { 621 - console.log("╔═══════════════════════════════════════════════════════════════╗") 622 - console.log("║ ORDER WORKFLOW DEMO - purus-ts ║") 623 - console.log("╚═══════════════════════════════════════════════════════════════╝\n") 624 - 625 - const services = createMockServices() 626 - 627 - // Test 1: Successful order 628 - console.log("─── 1. Successful Order Flow ───") 629 - const userId = UserId.fromString("user-1") 630 - const items = [ 631 - { 632 - productId: ProductId.fromString("prod-1"), 633 - name: "Widget", 634 - quantity: positiveInt(2) as PositiveInt, 635 - price: nonNegativeAmount(29.99) as NonNegativeAmount, 636 - }, 637 - ] 638 - const address: Address = { 639 - street: "123 Main St", 640 - city: "San Francisco", 641 - zip: "94105", 642 - country: "USA", 643 - } 644 - 645 - const successfulOrder = pipe( 646 - placeOrder(userId, items, address), 647 - provide(services) 648 - ) 649 - 650 - try { 651 - const shipped = await runPromise(successfulOrder) 652 - console.log(`\n ✓ Order ${shipped.id} shipped with tracking ${shipped.trackingNumber}\n`) 653 - } catch (e) { 654 - console.log(` ✗ ${formatOrderError(e as OrderError)}\n`) 655 - } 656 - 657 - // Test 2: Empty cart error 658 - console.log("─── 2. Empty Cart Error ───") 659 - const emptyOrder = pipe( 660 - placeOrder(userId, [], address), 661 - provide(services) 662 - ) 663 - 664 - try { 665 - await runPromise(emptyOrder) 666 - } catch (e) { 667 - console.log(` ✗ ${formatOrderError(e as OrderError)}\n`) 668 - } 669 - 670 - // Test 3: Invalid address error 671 - console.log("─── 3. Invalid Address Error ───") 672 - const badAddress: Address = { street: "", city: "", zip: "", country: "" } 673 - const badAddressOrder = pipe( 674 - placeOrder(userId, items, badAddress), 675 - provide(services) 676 - ) 677 - 678 - try { 679 - await runPromise(badAddressOrder) 680 - } catch (e) { 681 - console.log(` ✗ ${formatOrderError(e as OrderError)}\n`) 682 - } 683 - 684 - // Test 4: Error recovery with fallback 685 - console.log("─── 4. Error Recovery ───") 686 - const orderWithRecovery = pipe( 687 - placeOrder(userId, items, badAddress), 688 - catchAll(error => { 689 - console.log(` Caught error: ${formatOrderError(error)}`) 690 - console.log(` Retrying with valid address...`) 691 - return placeOrder(userId, items, address) 692 - }), 693 - provide(services) 694 - ) 695 - 696 - try { 697 - const recovered = await runPromise(orderWithRecovery) 698 - console.log(` ✓ Recovered! Order ${recovered.id} shipped\n`) 699 - } catch (e) { 700 - console.log(` ✗ Recovery failed: ${formatOrderError(e as OrderError)}\n`) 701 - } 702 - 703 - console.log("═══════════════════════════════════════════════════════════════") 704 - console.log("Demo complete!") 705 - } 706 - 707 - demo() 708 - .catch(console.error) 709 - .finally(() => process.exit(0))
+1 -1
examples/workflow-engine/README.md
··· 23 23 24 24 ## Without purus 25 25 26 - See `without-purus.ts` - realistic code showing: 26 + See `without-purus.ts`: 27 27 - All IDs are `string` - easy to swap arguments 28 28 - Runtime status checks before every operation 29 29 - `switch` statements with forgotten cases