diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1bbdec6b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Ensure consistent line endings +* text=auto eol=lf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary \ No newline at end of file diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify-posix.yaml similarity index 69% rename from .github/workflows/verify.yaml rename to .github/workflows/verify-posix.yaml index ba15f93c..a99028f7 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify-posix.yaml @@ -1,17 +1,22 @@ -name: Verify +name: Verify (POSIX) on: push: - branches: main + branches: + - main pull_request: - branches: main + branches: + - main permissions: contents: read jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - name: checkout @@ -21,7 +26,6 @@ jobs: uses: denoland/setup-deno@v2 with: deno-version: v2.x - - name: reload dependencies run: deno cache --reload **/mod.ts @@ -31,4 +35,4 @@ jobs: - run: deno lint - - run: deno test --allow-net --allow-read --allow-write --allow-env --allow-run + - run: deno test --allow-net --allow-read --allow-write --allow-env --allow-run --allow-ffi diff --git a/.github/workflows/verify-windows.yaml b/.github/workflows/verify-windows.yaml new file mode 100644 index 00000000..16c4e0e6 --- /dev/null +++ b/.github/workflows/verify-windows.yaml @@ -0,0 +1,60 @@ +name: Verify (Windows) + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + strategy: + matrix: + shell: + # tests pass in all shells, but in powershell and pwsh, they `echo $?` as false + # this causes tests to fail in CI. We'll need to run only bash and cmd until + # we figure this out. + # - powershell + # - pwsh + - bash + - cmd + defaults: + run: + shell: ${{matrix.shell}} + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - run: deno fmt --check + + - run: deno lint + + - name: "Test (${{matrix.shell}})" + if: matrix.shell == 'powershell' || matrix.shell == 'pwsh' + run: | + $env:SHELL = "${{matrix.shell}}" + deno test --allow-net --allow-read --allow-write --allow-env --allow-run --allow-ffi --trace-leaks + + - name: "Test (${{matrix.shell}})" + if: matrix.shell == 'cmd' + run: | + set SHELL=${{matrix.shell}} + deno test --allow-net --allow-read --allow-write --allow-env --allow-run --allow-ffi --trace-leaks + + - name: "Test (${{matrix.shell}})" + if: matrix.shell == 'bash' + run: | + export SHELL=${{matrix.shell}} + deno test --allow-net --allow-read --allow-write --allow-env --allow-run --allow-ffi --trace-leaks diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..79cd7f8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,2 @@ +Always check syntax after making code changes. Always run `deno fmt` and +`deno lint` before commiting. diff --git a/bdd/README.md b/bdd/README.md index 97eddf69..c827d3e9 100644 --- a/bdd/README.md +++ b/bdd/README.md @@ -24,7 +24,7 @@ Add to your `deno.json` imports: ```json { "imports": { - "@effectionx/bdd": "npm:@effectionx/bdd" + "@effectionx/bdd": "jsr:@effectionx/bdd" } } ``` diff --git a/bdd/deno.json b/bdd/deno.json index 8cab10f6..cdf2fc77 100644 --- a/bdd/deno.json +++ b/bdd/deno.json @@ -5,7 +5,7 @@ "license": "MIT", "imports": { "effection": "npm:effection@^3", - "@effectionx/test-adapter": "npm:@effectionx/test-adapter@^0.5.1", + "@effectionx/test-adapter": "jsr:@effectionx/test-adapter@^0.1.0", "@std/testing": "jsr:@std/testing@^1" } } diff --git a/context-api/context-api.test.ts b/context-api/context-api.test.ts index 55164d20..1850e653 100644 --- a/context-api/context-api.test.ts +++ b/context-api/context-api.test.ts @@ -1,10 +1,10 @@ -import { describe, it } from "bdd"; import { expect } from "@std/expect"; -import { type Operation, run, scoped } from "effection"; +import { type Operation, scoped } from "effection"; +import { describe, it } from "@effectionx/bdd"; import { createApi } from "@effectionx/context-api"; describe("context api", () => { - it("can invoke a handler from anywhere", async () => { + it("can invoke a handler from anywhere", function* () { let { one, two } = createApi("numbers", { *one() { return 1; @@ -14,13 +14,11 @@ describe("context api", () => { }, }).operations; - await run(function* () { - expect(yield* one()).toEqual(1); - expect(yield* two()).toEqual(2); - }); + expect(yield* one()).toEqual(1); + expect(yield* two()).toEqual(2); }); - it("can have handlers that are not functions", async () => { + it("can have handlers that are not functions", function* () { let { one } = createApi("one", { one: { *[Symbol.iterator]() { @@ -28,13 +26,10 @@ describe("context api", () => { }, }, }).operations; - - await run(function* () { - expect(yield* one).toEqual(1); - }); + expect(yield* one).toEqual(1); }); - it("can add middleware around operations and operation functions", async () => { + it("can add middleware around operations and operation functions", function* () { let numbers = createApi("numbers", { one: { *[Symbol.iterator]() { @@ -47,23 +42,31 @@ describe("context api", () => { }); let { one, two } = numbers.operations; + yield* numbers.around({ + *one(args, next) { + return (yield* next(...args)) * 2; + }, + *two(args, next) { + return (yield* next(...args)) * 2; + }, + }); - await run(function* () { - yield* numbers.around({ - *one(args, next) { - return (yield* next(...args)) * 2; - }, - *two(args, next) { - return (yield* next(...args)) * 2; - }, - }); + expect(yield* one).toEqual(2); + expect(yield* two()).toEqual(4); + }); - expect(yield* one).toEqual(2); - expect(yield* two()).toEqual(4); + it("passes arguments to an operation", function* () { + const math = createApi("math", { + *add(left: number, right: number): Operation { + return left + right; + }, }); + + const { add } = math.operations; + expect(yield* add(1, 1)).toEqual(2); }); - it("passes arguments to an operation", async () => { + it("can modify arguments to operations with middleware", function* () { const math = createApi("math", { *add(left: number, right: number): Operation { return left + right; @@ -71,81 +74,59 @@ describe("context api", () => { }); const { add } = math.operations; - - await run(function* () { - expect(yield* add(1, 1)).toEqual(2); + yield* math.around({ + *add([left, right], next) { + return yield* next(left + 1, right + 1); + }, }); + + expect(yield* add(1, 1)).toEqual(4); }); - it("can modify arguments to operations with middleware", async () => { + it("treats the middleware contextually", function* () { const math = createApi("math", { *add(left: number, right: number): Operation { return left + right; }, }); - const { add } = math.operations; - - await run(function* () { + yield* scoped(function* () { yield* math.around({ *add([left, right], next) { return yield* next(left + 1, right + 1); }, }); - expect(yield* add(1, 1)).toEqual(4); }); + + expect(yield* add(1, 1)).toEqual(2); }); - it("treats the middleware contextually", async () => { + it("chains multiple middleware", function* () { const math = createApi("math", { *add(left: number, right: number): Operation { return left + right; }, }); - const { add } = math.operations; - await run(function* () { - yield* scoped(function* () { - yield* math.around({ - *add([left, right], next) { - return yield* next(left + 1, right + 1); - }, - }); - expect(yield* add(1, 1)).toEqual(4); - }); - - expect(yield* add(1, 1)).toEqual(2); + yield* math.around({ + *add([left, right], next) { + return 10 + (yield* next(left, right)); + }, }); - }); - it("chains multiple middleware", async () => { - const math = createApi("math", { - *add(left: number, right: number): Operation { - return left + right; + yield* math.around({ + *add([left, right], next) { + return 100 + (yield* next(left, right)); }, }); - await run(function* () { - yield* math.around({ - *add([left, right], next) { - return 10 + (yield* next(left, right)); - }, - }); - - yield* math.around({ - *add([left, right], next) { - return 100 + (yield* next(left, right)); - }, - }); - - yield* math.around({ - *add([left, right], next) { - return 20 + (yield* next(left, right)); - }, - }); - - expect(yield* math.operations.add(5, 15)).toEqual(150); + yield* math.around({ + *add([left, right], next) { + return 20 + (yield* next(left, right)); + }, }); + + expect(yield* math.operations.add(5, 15)).toEqual(150); }); }); diff --git a/context-api/deno.json b/context-api/deno.json index 8a0af60c..4e5dee0f 100644 --- a/context-api/deno.json +++ b/context-api/deno.json @@ -5,6 +5,7 @@ "exports": "./mod.ts", "imports": { "effection": "npm:effection@^3", - "@std/expect": "jsr:@std/expect" + "@std/expect": "jsr:@std/expect@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2" } } diff --git a/deno.json b/deno.json index 99b79261..8f1d9f28 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,5 @@ { "imports": { - "bdd": "jsr:@std/testing@1/bdd", - "expect": "jsr:@std/expect@1", "effection": "npm:effection@^3", "@deno/dnt": "jsr:@deno/dnt@0.42.3", "@std/path": "jsr:@std/path@^1", @@ -39,6 +37,7 @@ "./websocket", "./worker", "./stream-helpers", - "./signals" + "./signals", + "./process" ] } diff --git a/downgraded-dependencies.md b/downgraded-dependencies.md new file mode 100644 index 00000000..2f51f89e --- /dev/null +++ b/downgraded-dependencies.md @@ -0,0 +1,178 @@ +NOTE: This document is temporary. It'll be depeted once all of the versions were +resolved. + +# Path-Based Dependencies Analysis + +Projects that depend on other projects using explicit paths instead of versioned +registry dependencies. + +## Projects with Path-Based Dependencies: + +### 1. watch (`@effectionx/watch`) + +- `"@effectionx/process": "../process/mod.ts"` (watch/deno.json:21) +- `"@effectionx/stream-helpers": "../stream-helpers/mod.ts"` + (watch/deno.json:22) + +### 2. process (`@effectionx/process`) + +- `"@effectionx/stream-helpers": "../stream-helpers/mod.ts"` + (process/deno.json:13) + +## Notes + +All other projects in the monorepo use versioned dependencies from registries +(npm: or jsr:) rather than path-based references. + +--- + +# Effection Version Downgrades (v4 → v3) + +Projects that were downgraded from Effection v4 to v3: + +### 1. websocket (`@effectionx/websocket`) + +- Downgraded in commit `26ea6ec` - "[websocket] Downgraded to v3 and switched to + @effectionx/bdd" +- From: `4.0.0-beta.2` → To: `^3` + +### 2. worker (`@effectionx/worker`) + +- Downgraded in commit `7faf1f5` - "Downgraded worker to v3 but with failure" +- From: `4.0.0-beta.2` → To: `^3` + +### 3. test-adapter (`@effectionx/test-adapter`) + +- Downgraded in commits `b0840a4` and `51890db` - "Downgrade effection to 3 in + test-adapter" +- From: `4.0.0-beta.2` → To: `^3` + +### 4. timebox (`@effectionx/timebox`) + +- Downgraded in commit `633cf21` - "timebox: Refactor tests to use + @effectionx/deno-testing-bdd" +- From: `4.0.0-beta.2` → To: `^3` + +### 5. watch (`@effectionx/watch`) + +- Downgraded in commit `b725a62` - "watch tests are passing" +- From: `4.0.0-beta.2` → To: `3.6.0` (later changed to `^3`) +- Also downgraded in commit `bfbea7b` - "WIP" +- From: `4.0.0-alpha.6` → To: `3.1.0` + +### 6. jsonl-store (`@effectionx/jsonl-store`) + +- Downgraded in commit `97e937e` - "[jsonl-store] Switched to @effectionx/bdd" +- From: `4.0.0-beta.2` → To: `^3` + +### 7. task-buffer (`@effectionx/task-buffer`) + +- Downgraded in commit `50e072e` - "task-buffer: Refactor tests to use + @effectionx/deno-testing-bdd" +- From: `4.0.0-beta.2` → To: `^3` + +### 8. raf (`@effectionx/raf`) + +- Downgraded in commit `c48921c` - "Failing test" +- From: `4.0.0-alpha.7` → To: `3.4.0` (later changed to `^3`) + +## Summary + +- **Total projects downgraded:** 8 +- **Common target version:** `^3` (Effection v3) +- **Primary reason:** Most downgrades occurred during migration to + `@effectionx/bdd` testing framework + +--- + +# Published Versions on JSR + +Current published versions for downgraded packages: + +### 1. websocket (`@effectionx/websocket`) + +- 2.0.1 +- 2.0.2 +- 2.1.0 (latest) + +### 2. worker (`@effectionx/worker`) + +- 0.1.1 +- 0.1.2 +- 0.2.0 (latest) + +### 3. test-adapter (`@effectionx/test-adapter`) + +- 0.1.1 +- 0.1.2 +- 0.2.0 +- 0.3.0 +- 0.4.0 +- 0.5.0 +- 0.5.1 (latest) + +### 4. timebox (`@effectionx/timebox`) + +- 0.1.1 +- 0.1.2 +- 0.2.0 (latest) + +### 5. watch (`@effectionx/watch`) + +- 0.1.2 +- 0.1.3 +- 0.2.0 (latest) + +### 6. jsonl-store (`@effectionx/jsonl-store`) + +- 0.1.1 +- 0.1.2 +- 0.2.0 (latest) + +### 7. task-buffer (`@effectionx/task-buffer`) + +- 1.0.2 +- 1.0.3 +- 1.1.0 (latest) + +### 8. raf (`@effectionx/raf`) + +- 1.0.0-alpha.0 +- 1.0.0-alpha.1 (latest) + +--- + +# Suggested Downgrade Versions + +Versions lower than the lowest published version for each package: + +1. **websocket** - Current lowest: 2.0.1 → **Suggested: 2.0.0** ✓ Not published +2. **worker** - Current lowest: 0.1.1 → **Suggested: 0.1.0** ✓ Not published +3. **test-adapter** - Current lowest: 0.1.1 → **Suggested: 0.1.0** ✓ Not + published +4. **timebox** - Current lowest: 0.1.1 → **Suggested: 0.1.0** ✓ Not published +5. **watch** - Current lowest: 0.1.2 → **Suggested: 0.1.1** ✓ Not published +6. **jsonl-store** - Current lowest: 0.1.1 → **Suggested: 0.1.0** ✓ Not + published +7. **task-buffer** - Current lowest: 1.0.2 → **Suggested: 1.0.1** ✓ Not + published +8. **raf** - Current lowest: 1.0.0-alpha.0 → **Suggested: 0.0.1** ✓ Not + published + +**Verification Status:** All suggested versions confirmed to NOT exist on JSR +(verified on 2025-10-17) + +--- + +# Dependency Updates Required + +Packages that depend on downgraded packages needed their dependency versions +updated: + +1. **worker** (`@effectionx/worker`) + - Depends on: `@effectionx/timebox` + - Updated from: `^0.2.0` → `^0.1.0` + +2. **bdd** (`@effectionx/bdd`) + - Depends on: `@effectionx/test-adapter` + - Updated from: `^0.5.1` → `^0.1.0` diff --git a/fx/deno.json b/fx/deno.json index 174f4bfc..d8644a36 100644 --- a/fx/deno.json +++ b/fx/deno.json @@ -4,6 +4,8 @@ "exports": "./mod.ts", "license": "MIT", "imports": { - "effection": "npm:effection@^3" + "effection": "npm:effection@^3", + "@std/expect": "jsr:@std/expect@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2" } } diff --git a/fx/parallel.test.ts b/fx/parallel.test.ts index e2ba4d57..7bfcfba7 100644 --- a/fx/parallel.test.ts +++ b/fx/parallel.test.ts @@ -1,13 +1,11 @@ -import { describe, it } from "bdd"; -import { expect } from "expect"; -import { each, Err, Ok, run, sleep, spawn } from "effection"; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; +import { each, Err, Ok, sleep, spawn } from "effection"; import { parallel } from "./parallel.ts"; import type { Operation, Result } from "effection"; -const test = describe("parallel()"); - interface Defer { promise: Promise; resolve: (t: T) => void; @@ -25,138 +23,109 @@ function defer(): Defer { return { resolve, reject, promise }; } -it( - test, - "should return an immediate channel with results as they are completed", - async () => { - const result = await run(function* () { - const results = yield* parallel([ - function* () { - yield* sleep(20); - return "second"; - }, - function* () { - yield* sleep(10); - return "first"; - }, - ]); - - const res: Result[] = []; - for (const val of yield* each(results.immediate)) { - res.push(val); - yield* each.next(); - } +describe("parallel()", () => { + it("should return an immediate channel with results as they are completed", function* () { + const results = yield* parallel([ + function* () { + yield* sleep(20); + return "second"; + }, + function* () { + yield* sleep(10); + return "first"; + }, + ]); + + const res: Result[] = []; + for (const val of yield* each(results.immediate)) { + res.push(val); + yield* each.next(); + } - yield* results; - return res; - }); + yield* results; - expect(result).toEqual([Ok("first"), Ok("second")]); - }, -); - -it( - test, - "should return a sequence channel with results preserving array order as results", - async () => { - const result = await run(function* () { - const results = yield* parallel([ - function* () { - yield* sleep(20); - return "second"; - }, - function* () { - yield* sleep(10); - return "first"; - }, - ]); - - const res: Result[] = []; - for (const val of yield* each(results.sequence)) { - res.push(val); - yield* each.next(); - } + expect(res).toEqual([Ok("first"), Ok("second")]); + }); - yield* results; - return res; - }); + it("should return a sequence channel with results preserving array order as results", function* () { + const results = yield* parallel([ + function* () { + yield* sleep(20); + return "second"; + }, + function* () { + yield* sleep(10); + return "first"; + }, + ]); + + const res: Result[] = []; + for (const val of yield* each(results.sequence)) { + res.push(val); + yield* each.next(); + } - expect(result).toEqual([Ok("second"), Ok("first")]); - }, -); - -it( - test, - "should return all the result in an array, preserving order", - async () => { - const result = await run(function* () { - const para = yield* parallel([ - function* () { - yield* sleep(20); - return "second"; - }, - function* () { - yield* sleep(10); - return "first"; - }, - ]); - - return yield* para; - }); + yield* results; - expect(result).toEqual([Ok("second"), Ok("first")]); - }, -); + expect(res).toEqual([Ok("second"), Ok("first")]); + }); -it(test, "should return empty array", async () => { - let actual; - await run(function* (): Operation { - const results = yield* parallel([]); - actual = yield* results; + it("should return all the result in an array, preserving order", function* () { + const para = yield* parallel([ + function* () { + yield* sleep(20); + return "second"; + }, + function* () { + yield* sleep(10); + return "first"; + }, + ]); + + expect(yield* para).toEqual([Ok("second"), Ok("first")]); }); - expect(actual).toEqual([]); -}); -it(test, "should resolve all async items", async () => { - const two = defer(); + it("should return empty array", function* (): Operation { + const results = yield* parallel([]); + expect(yield* results).toEqual([]); + }); - function* one() { - yield* sleep(5); - return 1; - } + it("should resolve all async items", function* () { + const two = defer(); - const result = await run(function* () { + function* one() { + yield* sleep(5); + return 1; + } yield* spawn(function* () { yield* sleep(15); two.resolve(2); }); const results = yield* parallel([one, () => two.promise]); - return yield* results; + expect(yield* results).toEqual([Ok(1), Ok(2)]); }); - expect(result).toEqual([Ok(1), Ok(2)]); -}); - -it(test, "should stop all operations when there is an error", async () => { - let actual: Result[] = []; - const one = defer(); - const two = defer(); - - function* genFn() { - try { - const results = yield* parallel([() => one.promise, () => two.promise]); - actual = yield* results; - } catch (_) { - actual = [Err(new Error("should not get hit"))]; + it("should stop all operations when there is an error", function* () { + let actual: Result[] = []; + const one = defer(); + const two = defer(); + + function* genFn() { + try { + const results = yield* parallel([() => one.promise, () => two.promise]); + actual = yield* results; + } catch (_) { + actual = [Err(new Error("should not get hit"))]; + } } - } - const err = new Error("error"); - one.reject(err); - two.resolve(1); + const err = new Error("error"); + one.reject(err); + two.resolve(1); - await run(genFn); + yield* genFn(); - const expected = [Err(err), Ok(1)]; - expect(actual).toEqual(expected); + const expected = [Err(err), Ok(1)]; + expect(actual).toEqual(expected); + }); }); diff --git a/fx/race.test.ts b/fx/race.test.ts index 40769226..ee1fc7d3 100644 --- a/fx/race.test.ts +++ b/fx/race.test.ts @@ -1,18 +1,14 @@ -import { describe, it } from "bdd"; -import { expect } from "expect"; -import { run, sleep } from "effection"; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; +import { sleep } from "effection"; import { raceMap } from "./race.ts"; -const test = describe("raceMap()"); - -it( - test, - "should return the result of the first completed operation", - async () => { - let winner; - - const result = await run(function* () { +describe("raceMap()", () => { + it( + "should return the result of the first completed operation", + function* () { + let winner; const results = yield* raceMap({ first: function* () { yield* sleep(10); @@ -25,19 +21,14 @@ it( return "second"; }, }); - return results; - }); - - expect(winner).toBe("first"); - expect(Object.keys(result)).toEqual(["first"]); - }, -); - -it(test, "should halt other operations when one completes", async () => { - let winner; - let secondCompleted = false; + expect(winner).toBe("first"); + expect(Object.keys(results)).toEqual(["first"]); + }, + ); - const result = await run(function* () { + it("should halt other operations when one completes", function* () { + let winner; + let secondCompleted = false; const results = yield* raceMap({ first: function* () { yield* sleep(10); @@ -56,10 +47,8 @@ it(test, "should halt other operations when one completes", async () => { }, }); - return results; + expect(winner).toBe("first"); + expect(Object.keys(results)).toEqual(["first"]); + expect(secondCompleted).toBe(false); }); - - expect(winner).toBe("first"); - expect(Object.keys(result)).toEqual(["first"]); - expect(secondCompleted).toBe(false); }); diff --git a/fx/request.test.ts b/fx/request.test.ts index fb97b23c..bb4b8a46 100644 --- a/fx/request.test.ts +++ b/fx/request.test.ts @@ -1,33 +1,31 @@ -import { describe, it } from "bdd"; -import { expect } from "expect"; -import { run } from "effection"; +import { beforeEach, describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; import { json, request } from "./request.ts"; +import { ensure, until } from "effection"; // Ensure to run tests with --allow-net permission -const test = describe("request() and json()"); - -it(test, "should fetch a URL and return a response", async () => { - const result = await run(function* () { - const response = yield* request( - "https://jsonplaceholder.typicode.com/todos/1", +describe("request() and json()", () => { + let url: string; + beforeEach(function* () { + let server = Deno.serve( + () => + new Response(JSON.stringify({ id: 1, title: "do things" }), { + headers: new Headers({ + "Content-Type": "application/json", + }), + }), ); - return response; - }); - expect(result.ok).toBe(true); - expect(result.status).toBe(200); -}); + url = `http://localhost:${server.addr.port}/todos/1`, + yield* ensure(() => until(server.shutdown())); + }); -it(test, "should parse JSON from a response", async () => { - const result = await run(function* () { - const response = yield* request( - "https://jsonplaceholder.typicode.com/todos/1", - ); + it("returns a response that can be parsed with json", function* () { + const response = yield* request(url); const data = yield* json(response); - return data; - }); - expect(result).toHaveProperty("id", 1); - expect(result).toHaveProperty("title"); + expect(data).toHaveProperty("id", 1); + expect(data).toHaveProperty("title"); + }); }); diff --git a/fx/request.ts b/fx/request.ts index 2b0fc266..a95bf53b 100644 --- a/fx/request.ts +++ b/fx/request.ts @@ -1,14 +1,14 @@ -import { call, type Operation, useAbortSignal } from "effection"; +import { type Operation, until, useAbortSignal } from "effection"; export function* request( url: string | URL | Request, opts?: RequestInit, ): Operation { const signal = yield* useAbortSignal(); - const response = yield* call(() => fetch(url, { signal, ...opts })); + const response = yield* until(fetch(url, { signal, ...opts })); return response; } -export function* json(response: Response): Operation { - return yield* call(() => response.json()); +export function json(response: Response): Operation { + return until(response.json()); } diff --git a/fx/safe.test.ts b/fx/safe.test.ts index 61918651..bcc50b20 100644 --- a/fx/safe.test.ts +++ b/fx/safe.test.ts @@ -1,47 +1,38 @@ -import { describe, it } from "bdd"; -import { expect } from "expect"; -import { call, run } from "effection"; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; +import { call } from "effection"; -const tests = describe("call()"); - -it(tests, "should call the generator function", async () => { - expect.assertions(1); - function* me() { - return "valid"; - } - - await run(function* () { +describe("call()", () => { + it("should call the generator function", function* () { + expect.assertions(1); + function* me() { + return "valid"; + } const result = yield* call(me); expect(result).toBe("valid"); }); -}); -it(tests, "should return an Err()", async () => { - expect.assertions(1); - const err = new Error("bang!"); - function* me() { - throw err; - } - - await run(function* () { + it("should return an Err()", function* () { + expect.assertions(1); + const err = new Error("bang!"); + function* me() { + throw err; + } try { yield* call(me); } catch (err) { expect(err).toEqual(err); } }); -}); - -it(tests, "should call a promise", async () => { - expect.assertions(1); - const me = () => - new Promise((resolve) => { - setTimeout(() => { - resolve("valid"); - }, 10); - }); - await run(function* () { + it("should call a promise", function* () { + expect.assertions(1); + const me = () => + new Promise((resolve) => { + setTimeout(() => { + resolve("valid"); + }, 10); + }); const result = yield* call(me); expect(result).toEqual("valid"); }); diff --git a/jsonl-store/README.md b/jsonl-store/README.md index c9ab27de..8cc975a2 100644 --- a/jsonl-store/README.md +++ b/jsonl-store/README.md @@ -14,7 +14,7 @@ You can use the default store client which will write store to `join(import.meta.dirname ?? Deno.cwd(), ".store")`. ```ts -import { run } from "npm:effection@4.0.0-alpha.3"; +import { run } from "npm:effection@^3"; import { useStore } from "jsr:@effectionx/jsonl-store"; await run(function* () { @@ -30,7 +30,7 @@ You can create a custom store location by using `JSONLStore.from` function. It ensures that you do not forget to add a trailing forward slash to the path. ```ts -import { run } from "npm:effection@4.0.0-alpha.3"; +import { run } from "npm:effection@^3"; import { JSONLStore, StoreContext, @@ -50,7 +50,7 @@ await run(function* () { You can write to the store at a given key and append to the same key. ```ts -import { run } from "npm:effection@4.0.0-alpha.3"; +import { run } from "npm:effection@^3"; import { useStore } from "jsr:@effectionx/jsonl-store"; await run(function* () { @@ -71,7 +71,7 @@ content to the same file makes it easy to collocate relevant entities. Reading values from a key produces a stream of all values from the given key. ```ts -import { run } from "npm:effection@4.0.0-alpha.3"; +import { run } from "npm:effection@^3"; import { useStore } from "jsr:@effectionx/jsonl-store"; await run(function* () { diff --git a/jsonl-store/context.test.ts b/jsonl-store/context.test.ts index 52e79f2f..9d35d0a2 100644 --- a/jsonl-store/context.test.ts +++ b/jsonl-store/context.test.ts @@ -1,14 +1,10 @@ -import { run } from "effection"; -import { describe, it } from "@std/testing/bdd"; +import { describe, it } from "@effectionx/bdd"; import { useStore } from "./mod.ts"; -import { expect } from "expect"; +import { expect } from "@std/expect"; describe("StoreContext", () => { - it("allows accessing context without initializing", async () => { - let store; - await run(function* () { - store = yield* useStore(); - }); + it("allows accessing context without initializing", function* () { + let store = yield* useStore(); expect(store).toBeDefined(); }); }); diff --git a/jsonl-store/deno.json b/jsonl-store/deno.json index b43d539b..8fa92f95 100644 --- a/jsonl-store/deno.json +++ b/jsonl-store/deno.json @@ -1,15 +1,15 @@ { "name": "@effectionx/jsonl-store", - "version": "0.2.0", + "version": "0.1.0", "exports": "./mod.ts", "license": "MIT", "imports": { - "effection": "npm:effection@4.0.0-beta.2", + "effection": "npm:effection@^3", "@std/json": "jsr:@std/json@^1", "@std/streams": "jsr:@std/streams@^1", "@std/fs": "jsr:@std/fs@^1", "@std/path": "jsr:@std/path@^1", - "@std/testing": "jsr:@std/testing@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2", "@std/expect": "jsr:@std/expect@^1" } } diff --git a/jsonl-store/jsonl.test.ts b/jsonl-store/jsonl.test.ts index c4148a70..1d09e257 100644 --- a/jsonl-store/jsonl.test.ts +++ b/jsonl-store/jsonl.test.ts @@ -1,12 +1,13 @@ import { expect } from "@std/expect"; import { dirname, join } from "@std/path"; -import { beforeEach, describe, it } from "@std/testing/bdd"; -import { each, run } from "effection"; +import { beforeEach, describe, it } from "@effectionx/bdd"; +import { each, stream, until } from "effection"; import { mkdir } from "node:fs"; import { promisify } from "node:util"; import { JSONLStore } from "./jsonl.ts"; import type { Store } from "./types.ts"; + // using promisify there because Deno's ensure doesn't work // correctly in Node. We should run these tests in Node // to make sure that it'll work in Node too. @@ -33,17 +34,17 @@ describe("JSONLStore", () => { file.close(); } - beforeEach(async () => { - tmpDir = await Deno.makeTempDir(); + beforeEach(function* () { + tmpDir = yield* until(Deno.makeTempDir()); store = JSONLStore.from({ location: tmpDir }); }); describe("from", () => { - it("ensures trailing slash for string path", () => { + it("ensures trailing slash for string path", function* () { const store = JSONLStore.from({ location: "/foo" }); expect(`${store.location}`).toEqual("file:///foo/"); }); - it("ensures trailing slash for URL", () => { + it("ensures trailing slash for URL", function* () { const store = JSONLStore.from({ location: new URL(".cache", "file:///usr/"), }); @@ -51,101 +52,87 @@ describe("JSONLStore", () => { }); }); - it("writes to a file", async () => { - await run(function* () { - yield* store.write("hello", "world"); - }); - expect(await readTmpFile("hello.jsonl")).toBe('"world"\n'); + it("writes to a file", function* () { + yield* store.write("hello", "world"); + expect(yield* until(readTmpFile("hello.jsonl"))).toBe('"world"\n'); }); - it("appends to a file", async () => { - await run(function* () { - yield* store.write("hello", "1"); - yield* store.append("hello", "2"); - }); - expect(await readTmpFile("hello.jsonl")).toBe('"1"\n"2"\n'); + it("appends to a file", function* () { + yield* store.write("hello", "1"); + yield* store.append("hello", "2"); + expect(yield* until(readTmpFile("hello.jsonl"))).toBe('"1"\n"2"\n'); }); describe("clearing store", () => { - beforeEach(async () => { - await writeTmpFile("hello.jsonl", "world\n"); + beforeEach(function* () { + yield* until(writeTmpFile("hello.jsonl", "world\n")); }); - it("clears store when called clear", async () => { - await run(function* () { - yield* store.clear(); - }); + it("clears store when called clear", function* () { + yield* store.clear(); const entries = []; - for await (const dirEntry of Deno.readDir(tmpDir)) { + for (const dirEntry of yield* each(stream(Deno.readDir(tmpDir)))) { entries.push(dirEntry); + yield* each.next(); } expect(entries).toHaveLength(0); }); }); describe("reading content of a file", () => { - beforeEach(async () => { - await Deno.writeTextFile(join(tmpDir, "test.jsonl"), `1\n2\n3\n`); + beforeEach(function* () { + yield* until(Deno.writeTextFile(join(tmpDir, "test.jsonl"), `1\n2\n3\n`)); }); - it("streams multiple items", async () => { + it("streams multiple items", function* () { const items: number[] = []; - await run(function* () { - for (const item of yield* each(store.read("test"))) { - items.push(item); - yield* each.next(); - } - }); + for (const item of yield* each(store.read("test"))) { + items.push(item); + yield* each.next(); + } expect(items).toEqual([1, 2, 3]); }); }); describe("checking presence of store", () => { - beforeEach(async () => { - await writeTmpFile("1.jsonl", "1\n"); + beforeEach(function* () { + yield* until(writeTmpFile("1.jsonl", "1\n")); }); - it("returns true when file exists", async () => { + it("returns true when file exists", function* () { let result: boolean | undefined = undefined; - await run(function* () { - result = yield* store.has("1"); - }); + result = yield* store.has("1"); expect(result).toBe(true); }); - it("returns false when file does not exists", async () => { + it("returns false when file does not exists", function* () { let result: boolean | undefined = undefined; - await run(function* () { - result = yield* store.has("2"); - }); + result = yield* store.has("2"); expect(result).toBe(false); }); }); describe("finds stored files using glob", () => { - beforeEach(async () => { - await writeTmpFile("subdir/1.jsonl", "1\n"); - await writeTmpFile("subdir/2.jsonl", "2\n"); - await writeTmpFile("subdir/3.jsonl", "3\n"); + beforeEach(function* () { + yield* until(writeTmpFile("subdir/1.jsonl", "1\n")); + yield* until(writeTmpFile("subdir/2.jsonl", "2\n")); + yield* until(writeTmpFile("subdir/3.jsonl", "3\n")); }); - it("streams multiple items", async () => { + it("streams multiple items", function* () { const items: number[] = []; - await run(function* () { - for (const item of yield* each(store.find("subdir/*"))) { - items.push(item); - yield* each.next(); - } - }); + for (const item of yield* each(store.find("subdir/*"))) { + items.push(item); + yield* each.next(); + } expect(items.sort()).toEqual([1, 2, 3]); }); + describe("multiple values in a single file", () => { - beforeEach(async () => { - await appendTmpFile("subdir/2.jsonl", "2.1\n"); + beforeEach(function* () { + yield* until(appendTmpFile("subdir/2.jsonl", "2.1\n")); }); - it("streams all lines from globbed files", async () => { + it("streams all lines from globbed files", function* () { const items: number[] = []; - await run(function* () { - for (const item of yield* each(store.find("subdir/*"))) { - items.push(item); - yield* each.next(); - } - }); + for (const item of yield* each(store.find("subdir/*"))) { + items.push(item); + yield* each.next(); + } expect(items.sort()).toEqual([1, 2, 2.1, 3]); }); }); diff --git a/jsonl-store/jsonl.ts b/jsonl-store/jsonl.ts index 7ce17428..fbf652f8 100644 --- a/jsonl-store/jsonl.ts +++ b/jsonl-store/jsonl.ts @@ -1,7 +1,7 @@ import { JsonParseStream } from "@std/json"; import { TextLineStream } from "@std/streams"; import { emptyDir, exists, walk } from "@std/fs"; -import { basename, dirname, globToRegExp, join, toFileUrl } from "@std/path"; +import { dirname, fromFileUrl, globToRegExp, join, toFileUrl } from "@std/path"; import { call, @@ -90,7 +90,7 @@ export class JSONLStore implements Store { * Returns content of a file as a stream * * ```ts - * import { each } from "npm:effection@4.0.0-alpha.3"; + * import { each } from "npm:effection@^3"; * import { useStore } from "jsr:@effectionx/jsonl-store"; * * const store = yield* useStore(); @@ -151,7 +151,7 @@ export class JSONLStore implements Store { *write(key: string, data: unknown): Operation { const location = new URL(`./${key}.jsonl`, this.location); - yield* mkdir(dirname(location.pathname), { recursive: true }); + yield* mkdir(dirname(fromFileUrl(location)), { recursive: true }); const file = yield* call(() => Deno.open(location, { @@ -204,7 +204,7 @@ export class JSONLStore implements Store { * Returns a stream of content from all files matching a glob * * ```ts - * import { each } from "npm:effection@4.0.0-alpha.3"; + * import { each } from "npm:effection@^3"; * import { useStore } from "jsr:@effectionx/jsonl-store"; * * const store = yield* useStore(); @@ -219,13 +219,13 @@ export class JSONLStore implements Store { * @returns Stream */ find(glob: string): Stream { - const { pathname } = this.location; + const root = fromFileUrl(this.location); - const reg = globToRegExp(`${pathname}/${glob}`, { + const reg = globToRegExp(join(root, glob), { globstar: true, }); - const files = walk(this.location, { + const files = walk(root, { includeDirs: false, includeFiles: true, match: [ @@ -240,10 +240,10 @@ export class JSONLStore implements Store { yield* spawn(function* () { for (const file of yield* each(stream(files))) { - const key = join( - dirname(file.path.replace(pathname, "")), - basename(file.name, ".jsonl"), - ); + const key = file.path + .replace(root, "") + .replaceAll(`\\`, `/`) + .replace(/\.jsonl$/, ""); for (const item of yield* each(read(key))) { queue.add(item); diff --git a/process/CHANGELOG.md b/process/CHANGELOG.md new file mode 100644 index 00000000..ebdd4ee8 --- /dev/null +++ b/process/CHANGELOG.md @@ -0,0 +1,269 @@ +# @effection/process + +## \[2.1.4] + +### Dependencies + +- Upgraded to `effection@2.0.8` + +## \[2.1.3] + +### Dependencies + +- Updated to latest `@effection/core` +- Updated to latest `effection` + +## \[2.1.2] + +- delegate `error` and `name` properties to underlying `Error`. fixes + https://github.com/thefrontside/effection/issues/675) + - Bumped due to a bump in effection. + - [84a66d7](https://github.com/thefrontside/effection/commit/84a66d799060ba2292fff2482d87bf6abafa7937) + Delegate error properties to original error on 2022-10-05 + +## \[2.1.1] + +- Bump the `ctrlc-windows` package to include the new build process and remove + all dependencies. + - [4b7ca17](https://github.com/thefrontside/effection/commit/4b7ca1791a776bd1a7e4b8d100554aa683ebc49f) + bump ctrlc-windows to 2.1.0 on 2022-09-21 + +## \[2.1.0] + +- The shell option now accepts a string which allows one to specify an exact + shell to run a command. This is helpful on windows as the default generally + doesn't handle bash-like syntax. + - [31d8512](https://github.com/thefrontside/effection/commit/31d85123535903820c7ee2c58891ad4f7ae385e5) + change file on 2022-03-29 + +## \[2.0.4] + +- Allow pass object with `Symbol.operation` as an operation + - Bumped due to a bump in effection. + - [3e7daa8](https://github.com/thefrontside/effection/commit/3e7daa82cce974ea6b4ff90764343594ae7cba13) + add changelog on 2022-01-26 + - [c623a84](https://github.com/thefrontside/effection/commit/c623a8448dfef764a03b3af6a6b0afa9ee834ba9) + remove fetch and process packages from changes list on 2022-01-26 + +## \[2.0.3] + +- Remove redundant node-fetch from dependencies + - Bumped due to a bump in effection. + - [b4a87d5](https://github.com/thefrontside/effection/commit/b4a87d525d270e53b92543676c9fb10c7fd1edd7) + Add change file for covector on 2022-01-24 + +## \[2.0.2] + +- Extract `AbortSignal` from `@effection/fetch` to `@effection/core` as a + resource + - Bumped due to a bump in effection. + - [8ac2e85](https://github.com/thefrontside/effection/commit/8ac2e8515ac2cb1ee6ed5a200f31d28024bfdae2) + Add covector change file on 2021-11-18 + - [b6d0e15](https://github.com/thefrontside/effection/commit/b6d0e1502ca8345bf488aef695b16fe7a5a5945d) + Patch for fetch and not minor on 2021-11-19 + +## \[2.0.1] + +- workaround borked 2.0 release https://status.npmjs.org/incidents/wy4002vc8ryc + - Bumped due to a bump in effection. + - [97711a7](https://github.com/thefrontside/effection/commit/97711a77419c8e539bff3060a9f3c1bae947f9b8) + Work around borked NPM release on 2021-10-12 + +## \[2.0.0] + +- Release Effection 2.0.0 + - [8bd89ad](https://github.com/thefrontside/effection/commit/8bd89ad40e42805ab6da0fd1b7a49beed9769865) + Add 2.0 changeset on %as + +## \[2.0.0-beta.21] + +- Yielding to something which is not an operation no longer throws an internal + error, but properly rejects the task. + - Bumped due to a bump in @effection/core. + - [a3ad19a](https://github.com/thefrontside/effection/commit/a3ad19a3177a731fee5cd2389ab898dee7b1788e) + Fix yielding non operation bug on 2021-10-07 + +## \[2.0.0-beta.20] + +- Fix a bug when using blockParent where the children are not getting halt on an + explicit halt. + - Bumped due to a bump in @effection/core. + - [1cd9803](https://github.com/thefrontside/effection/commit/1cd98033d2641989114f9589c7d887954fa66781) + Fix halting children for blockParent tasks on 2021-09-30 + +## \[2.0.0-beta.19] + +- Add Stream `toBuffer` and Stream `buffered` so we have both options on either + accessing the buffer directly or returning the stream. + - Bumped due to a bump in @effection/stream. + - [fe60532](https://github.com/thefrontside/effection/commit/fe60532c3f8cfdd8b53c324b7ea8e38e437f080f) + Add both toBuffer and buffered to Stream on 2021-09-30 + +## \[2.0.0-beta.18] + +- Stream `buffer` returns the actual buffer and gives direct access to it + - Bumped due to a bump in @effection/stream. + - [07c8f83](https://github.com/thefrontside/effection/commit/07c8f83b5968f347ca72795c447be411e66274ed) + Stream `buffer` returns the actual buffer on 2021-09-30 + +## \[2.0.0-beta.17] + +- + - [0248d79](https://github.com/thefrontside/effection/commit/0248d79a33dcfc4200b0832aba975c9cad08981e) + Add package readmes on 2021-09-28 +- The `stdout` and `stderr` properties now return `OutputStream` instead of + regular `Stream`. Addiotionally, the `buffered` option has been removed. + - [4e9cb50](https://github.com/thefrontside/effection/commit/4e9cb5006249a208c62f7de30c1fd16a713725c7) + Improve process streams on 2021-09-22 +- Split off `Stream` from subscription package into its own `@effection/stream` + package + - [248de1d](https://github.com/thefrontside/effection/commit/248de1dd31d172762d9601a2b5acd983dce61ab0) + Split `Stream` into its own package on 2021-09-27 + +## \[2.0.0-beta.16] + +- Adjust the propagation of errors for resources to make it possible to catch + errors from `init` + - Bumped due to a bump in @effection/core. + - [75a7248](https://github.com/thefrontside/effection/commit/75a7248ae13d1126bbcaf9b6223f348168e987d0) + Catch errors thrown during resource init on 2021-09-21 +- Enable support for resources in higher order operations `all`, `race` and + `withTimeout`. + - Bumped due to a bump in @effection/core. + - [bbe6cdc](https://github.com/thefrontside/effection/commit/bbe6cdc44184a7669278d0d01ad23a2a79a69e52) + Enable resource support for higher order operations on 2021-09-09 + +## \[2.0.0-beta.15] + +- Add shortcuts to create resolved/rejected/halted futures via + Future.resolve(123), etc... + - Bumped due to a bump in @effection/core. + - [9599dde](https://github.com/thefrontside/effection/commit/9599dde14e9bc3ba4ac7ea473e8624164727be0c) + Add shortcuts for resolves/rejected/halted future on 2021-09-08 + +## \[2.0.0-beta.14] + +- Improve naming scheme for process labels + - [82f31cd](https://github.com/thefrontside/effection/commit/82f31cd3fc8c490a795ee83ad61fd38fef18e8ea) + Improve naming scheme of exec labels on 2021-09-06 +- Apply labels to the operations. These will improve error output and show up in + the effection inspector. + - [74160a5](https://github.com/thefrontside/effection/commit/74160a51f9c937e8fd6c1690eba7494a51c88d8c) + label @effection/process Operations on 2021-07-20 + +## \[2.0.0-beta.13] + +- Allow channels to be named so their internal stream gets named + - Bumped due to a bump in @effection/channel. + - [c52018a](https://github.com/thefrontside/effection/commit/c52018a1035d551cef76a757d1dc29781b59c851) + Allow channels to be named on 2021-08-27 + +## \[2.0.0-beta.12] + +- Update core dependency + - Bumped due to a bump in @effection/mocha. + - [d92eee5](https://github.com/thefrontside/effection/commit/d92eee594fdb8dc6d8ab6a37b6aa362122e63f6e) + Update core dependency on 2021-08-16 + +## \[2.0.0-beta.11] + +- Use Object.create to wrap error objects rather than copying properties + - Bumped due to a bump in @effection/core. + - [a56ae2a](https://github.com/thefrontside/effection/commit/a56ae2af8a6247697b8b6253bd35b6d9e569613d) + Use Object.create to create error object with trace on 2021-08-16 + +## \[2.0.0-beta.10] + +- add `Task#spawn` operation to spawn new task with a specific scope + - Bumped due to a bump in @effection/core. + - [a71d65b](https://github.com/thefrontside/effection/commit/a71d65b77df5c337a78b7934edd181080eacf5bf) + Add changefile on 2021-07-27 + +## \[2.0.0-beta.9] + +- Add sideEffects field to package.json + - [383141d](https://github.com/thefrontside/effection/commit/383141dc556c6a781d98087f3b68085d5eb31173) + Add sideEffects field to package.json + ([#470](https://github.com/thefrontside/effection/pull/470)) on 2021-08-05 + +## \[2.0.0-beta.8] + +- The `dist` directory didn't contain the `esm` and `cjs` directory. We copy the + `package.json` for reference into the dist, and this broke the `files` + resolution. Switch to using `dist-cjs` and `dist-esm` which allows us to avoid + copying `package.json`. + - [63fbcfb](https://github.com/thefrontside/effection/commit/63fbcfb8151bb7434f1cb8c58bfed25012ad2727) + fix: @effection/core to ship dist/esm and dist/cjs on 2021-08-03 + - [7788e24](https://github.com/thefrontside/effection/commit/7788e2408bcff8180b24ce497043283c97b6dbaa) + fix: @effection/core to ship dist-esm and dist-cjs on 2021-08-03 + - [6923a0f](https://github.com/thefrontside/effection/commit/6923a0fa1a84cd0788f8c9c1600ccf7539b08bbf) + update change file with everything patched on 2021-08-03 + +## \[2.0.0-beta.7] + +- Add esm builds + - Bumped due to a bump in @effection/core. + - [6660a46](https://github.com/thefrontside/effection/commit/6660a466a50c9b9c36829c2d52448ebbc0e7e6fb) + Add esm build ([#462](https://github.com/thefrontside/effection/pull/462)) + on 2021-08-03 + +## \[2.0.0-beta.6] + +- remove accidentally compiled .js files from distributed sources + - Bumped due to a bump in @effection/mocha. + - [f0f0023](https://github.com/thefrontside/effection/commit/f0f002354743ae6d6f69bfe6df28ddc11d0f8be0) + add changefile on 2021-07-26 + +## \[2.0.0-beta.5] + +- Upgrade typescript to 4.3.5 and replace tsdx with tsc + - [121bd40](https://github.com/thefrontside/effection/commit/121bd40e17609a82bce649c5fed34ee0754681b7) + Add change file for typescript bump on 2021-07-23 + +## 2.0.0-beta.4 + +### Patch Changes + +- Updated dependencies \[e297c86] + - @effection/core@2.0.0-beta.4 + - @effection/channel@2.0.0-beta.4 + - @effection/events@2.0.0-beta.4 + - @effection/subscription@2.0.0-beta.4 + +## 2.0.0-beta.3 + +### Patch Changes + +- Updated dependencies \[3e77f29] +- Updated dependencies \[5d95e6d] +- Updated dependencies \[9700b45] +- Updated dependencies \[9700b45] + - @effection/subscription@2.0.0-beta.3 + - @effection/events@2.0.0-beta.3 + - @effection/core@2.0.0-beta.3 + - @effection/channel@2.0.0-beta.3 + +## 2.0.0-beta.2 + +### Patch Changes + +- Updated dependencies \[19414f0] +- Updated dependencies \[26a86cb] +- Updated dependencies \[9c76cc5] +- Updated dependencies \[f7e3344] +- Updated dependencies \[ac7c1ce] + - @effection/core@2.0.0-beta.2 + - @effection/channel@2.0.0-beta.2 + - @effection/events@2.0.0-beta.2 + - @effection/subscription@2.0.0-beta.2 + +## 2.0.0-beta.1 + +### Patch Changes + +- 0c6e263: release 2.0.0-beta +- Updated dependencies \[0c6e263] + - @effection/channel@2.0.0-beta.1 + - @effection/core@2.0.0-beta.1 + - @effection/events@2.0.0-beta.1 + - @effection/subscription@2.0.0-beta.1 diff --git a/process/README.md b/process/README.md new file mode 100644 index 00000000..3866cda0 --- /dev/null +++ b/process/README.md @@ -0,0 +1,12 @@ +# @effection/process + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Created by Frontside](https://img.shields.io/badge/created%20by-frontside-26abe8.svg)](https://frontside.com) +[![Chat on Discord](https://img.shields.io/discord/700803887132704931?Label=Discord)](https://discord.gg/Ug5nWH8) + +[Effection][Effection] is the structured concurrency toolkit for JavaScript. You +can find detailed information about using effection to manage system process in +node in the +[spawning processes guide](https://frontside.com/effection/docs/guides/processes) + +[Effection]: https://frontside.com/effection diff --git a/process/deno.json b/process/deno.json new file mode 100644 index 00000000..b0862798 --- /dev/null +++ b/process/deno.json @@ -0,0 +1,17 @@ +{ + "name": "@effectionx/process", + "exports": "./mod.ts", + "version": "0.5.0", + "license": "MIT", + "imports": { + "@types/cross-spawn": "npm:@types/cross-spawn@6.0.6", + "cross-spawn": "npm:cross-spawn@7.0.6", + "ctrlc-windows": "npm:ctrlc-windows@2.2.0", + "effection": "npm:effection@^3", + "shellwords": "npm:shellwords@^1.1.1", + "@std/expect": "jsr:@std/expect@1.0.17", + "@effectionx/stream-helpers": "jsr:@effectionx/stream-helpers@0.3.0", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2", + "@effectionx/signals": "npm:@effectionx/signals@0.3.0" + } +} diff --git a/process/mod.ts b/process/mod.ts new file mode 100644 index 00000000..a3673659 --- /dev/null +++ b/process/mod.ts @@ -0,0 +1,2 @@ +export * from "./src/exec.ts"; +export { type Daemon, daemon } from "./src/daemon.ts"; diff --git a/process/src/daemon.ts b/process/src/daemon.ts new file mode 100644 index 00000000..30f94f79 --- /dev/null +++ b/process/src/daemon.ts @@ -0,0 +1,35 @@ +import { type Operation, resource } from "effection"; + +import { + DaemonExitError, + exec, + type ExecOptions, + type ExitStatus, + type Process, +} from "./exec.ts"; + +export interface Daemon extends Operation, Process { +} + +/** + * Start a long-running process, like a web server that run perpetually. + * Daemon operations are expected to run forever, and if they exit pre-maturely + * before the operation containing them passes out of scope it raises an error. + */ +export function daemon( + command: string, + options: ExecOptions = {}, +): Operation { + return resource(function* (provide) { + // TODO: should we be able to terminate the process from here? + let process = yield* exec(command, options); + + yield* provide({ + *[Symbol.iterator]() { + let status: ExitStatus = yield* process.join(); + throw new DaemonExitError(status, command, options); + }, + ...process, + }); + }); +} diff --git a/process/src/eventemitter.ts b/process/src/eventemitter.ts new file mode 100644 index 00000000..7e0d5643 --- /dev/null +++ b/process/src/eventemitter.ts @@ -0,0 +1,58 @@ +import { createSignal, resource, withResolvers } from "effection"; +import type { Operation, Stream, Subscription } from "effection"; +import type { EventEmitter } from "node:stream"; + +/** + * Create a {@link Stream} of events from any EventEmitter. + * + * See the guide on [Streams and Subscriptions](https://frontside.com/effection/docs/collections) + * for details on how to use streams. + * + * @param target - the event target whose events will be streamed + * @param name - the name of the event to stream. E.g. "click" + * @returns a stream that will see one item for each event + */ +export function on< + T extends unknown[], +>(target: EventEmitter | null, eventName: string): Stream { + return resource(function* (provide) { + let signal = createSignal(); + + let listener = (...args: T) => signal.send(args); + + target?.on(eventName, listener); + + try { + yield* provide( + yield* signal as Operation< + Subscription + >, + ); + } finally { + target?.off(eventName, listener); + } + }); +} + +/** + * Create an {@link Operation} that yields the next event to be emitted by an EventEmitter. + * + * @param target - the event target to be watched + * @param name - the name of the event to watch. E.g. "click" + * @returns an Operation that yields the next emitted event + */ +export function once( + source: EventEmitter | null, + eventName: string, +): Operation { + const result = withResolvers(); + + let listener = (...args: unknown[]) => { + result.resolve(args as TArgs); + source?.off(eventName, listener); + }; + + source?.on(eventName, listener); + + return result.operation; +} diff --git a/process/src/exec.ts b/process/src/exec.ts new file mode 100644 index 00000000..da6f09e1 --- /dev/null +++ b/process/src/exec.ts @@ -0,0 +1,101 @@ +import { split } from "shellwords"; + +import { type Operation, spawn } from "effection"; +import type { + CreateOSProcess, + ExecOptions, + ExitStatus, + Process, + ProcessResult, +} from "./exec/api.ts"; +import { createPosixProcess } from "./exec/posix.ts"; +import { createWin32Process, isWin32 } from "./exec/win32.ts"; + +export * from "./exec/api.ts"; +export * from "./exec/error.ts"; + +export interface Exec extends Operation { + join(): Operation; + expect(): Operation; +} + +const createProcess: CreateOSProcess = (cmd, opts) => { + if (isWin32()) { + return createWin32Process(cmd, opts); + } else { + return createPosixProcess(cmd, opts); + } +}; + +/** + * Execute `command` with `options`. You should use this operation for processes + * that have a finite lifetime and on which you may wish to synchronize on the + * exit status. If you want to start a process like a server that spins up and runs + * forever, consider using `daemon()` + */ +export function exec(command: string, options: ExecOptions = {}): Exec { + let [cmd, ...args] = options.shell ? [command] : split(command); + let opts = { ...options, arguments: args.concat(options.arguments || []) }; + + return { + *[Symbol.iterator]() { + return yield* createProcess(cmd, opts); + }, + *join() { + const process = yield* createProcess(cmd, opts); + + let stdout = ""; + let stderr = ""; + + yield* spawn(function* () { + let subscription = yield* process.stdout; + let next = yield* subscription.next(); + while (!next.done) { + stdout += next.value; + next = yield* subscription.next(); + } + }); + + yield* spawn(function* () { + let subscription = yield* process.stderr; + let next = yield* subscription.next(); + while (!next.done) { + stderr += next.value; + next = yield* subscription.next(); + } + }); + + let status: ExitStatus = yield* process.join(); + + return { ...status, stdout, stderr }; + }, + *expect() { + const process = yield* createProcess(cmd, opts); + + let stdout = ""; + let stderr = ""; + + yield* spawn(function* () { + let subscription = yield* process.stdout; + let next = yield* subscription.next(); + while (!next.done) { + stdout += next.value; + next = yield* subscription.next(); + } + }); + + yield* spawn(function* () { + let subscription = yield* process.stderr; + let next = yield* subscription.next(); + while (!next.done) { + stderr += next.value; + next = yield* subscription.next(); + } + }); + + let status: ExitStatus = yield* process.expect(); + + return { ...status, stdout, stderr }; + }, + }; +} diff --git a/process/src/exec/api.ts b/process/src/exec/api.ts new file mode 100644 index 00000000..8f0a7cf8 --- /dev/null +++ b/process/src/exec/api.ts @@ -0,0 +1,82 @@ +import type { Operation } from "effection"; +import type { OutputStream } from "../helpers.ts"; + +// TODO: import from subscription package once #236 is merged +export interface Writable { + send(message: T): void; +} + +/** + * The process type is what is returned by the `exec` operation. It has all of + * standard io handles, and methods for synchronizing on return. + */ +export interface Process extends StdIO { + readonly pid: number; + + /** + * Completes once the process has finished regardless of whether it was + * successful or not. + */ + join(): Operation; + + /** + * Completes once the process has finished successfully. If the process does + * not complete successfully, it will raise an ExecError. + */ + expect(): Operation; +} + +export interface ExecOptions { + /** + * When not using passing the `shell` option all arguments must be passed + * as an array. + */ + arguments?: string[]; + + /** + * Map of environment variables to use for the process. + */ + env?: Record; + + /** + * Create an intermediate shell process; defaults to `false`. Useful if you + * need to handle glob expansion or passing environment variables. A truthy value + * will use an intermediate shell to interpret the command using the default system shell. + * However, if the value is a string, that will be used as the executable path + * for the intermediate shell. + */ + shell?: boolean | string; + + /** + * Sets the working directory of the process + */ + cwd?: string; +} + +export interface StdIO { + stdout: OutputStream; + stderr: OutputStream; + stdin: Writable; +} + +export interface ExitStatus { + /** + * exit code + * //TODO: is this pertinent on Windows? Do we need an 'OK' flag + */ + code?: number; + + /** + * If the process exited with a signal instead of an exit code, it + * is recorded here. + */ + signal?: string; +} + +export interface ProcessResult extends ExitStatus { + stdout: string; + stderr: string; +} +export interface CreateOSProcess { + (command: string, options: ExecOptions): Operation; +} diff --git a/process/src/exec/error.ts b/process/src/exec/error.ts new file mode 100644 index 00000000..b943b4f3 --- /dev/null +++ b/process/src/exec/error.ts @@ -0,0 +1,39 @@ +import type { ExecOptions, ExitStatus } from "./api.ts"; + +export class ExecError extends Error { + constructor( + public status: ExitStatus, + public command: string, + public options: ExecOptions, + ) { + super(); + } + + override name = "ExecError"; + + override get message(): string { + let code = this.status.code ? `code: ${this.status.code}` : null; + + let signal = this.status.signal ? `signal: ${this.status.signal}` : null; + + let env = `env: ${JSON.stringify(this.options.env || {})}`; + + let shell = this.options.shell ? `shell: ${this.options.shell}` : null; + + let cwd = this.options.cwd ? `cwd: ${this.options.cwd}` : null; + + let command = `$ ${this.command} ${this.options.arguments?.join(" ")}` + .trim(); + + return [code, signal, env, shell, cwd, command].filter((item) => !!item) + .join("\n"); + } +} + +export class DaemonExitError extends ExecError { + override name = "DaemonExitError"; + + override get message(): string { + return `daemon process quit unexpectedly\n${super.message}`; + } +} diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts new file mode 100644 index 00000000..08336b45 --- /dev/null +++ b/process/src/exec/posix.ts @@ -0,0 +1,133 @@ +import { spawn as spawnProcess } from "node:child_process"; +import { + all, + createSignal, + Err, + Ok, + type Result, + spawn, + withResolvers, +} from "effection"; +import process from "node:process"; +import { once } from "../eventemitter.ts"; +import { useReadable } from "../helpers.ts"; +import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts"; +import { ExecError } from "./error.ts"; + +type ProcessResultValue = [number?, string?]; + +export const createPosixProcess: CreateOSProcess = function* createPosixProcess( + command, + options, +) { + let processResult = withResolvers>(); + + // Killing all child processes started by this command is surprisingly + // tricky. If a process spawns another processes and we kill the parent, + // then the child process is NOT automatically killed. Instead we're using + // the `detached` option to force the child into its own process group, + // which all of its children in turn will inherit. By sending the signal to + // `-pid` rather than `pid`, we are sending it to the entire process group + // instead. This will send the signal to all processes started by the child + // process. + // + // More information here: https://unix.stackexchange.com/questions/14815/process-descendants + let childProcess = spawnProcess(command, options.arguments || [], { + detached: true, + shell: options.shell, + env: options.env, + cwd: options.cwd, + stdio: "pipe", + }); + + let { pid } = childProcess; + + let io = { + stdout: yield* useReadable(childProcess.stdout), + stderr: yield* useReadable(childProcess.stderr), + stdoutDone: withResolvers(), + stderrDone: withResolvers(), + }; + + let stdout = createSignal(); + let stderr = createSignal(); + + yield* spawn(function* () { + let next = yield* io.stdout.next(); + while (!next.done) { + stdout.send(next.value); + next = yield* io.stdout.next(); + } + stdout.close(); + io.stdoutDone.resolve(); + }); + + yield* spawn(function* () { + let next = yield* io.stderr.next(); + while (!next.done) { + stderr.send(next.value); + next = yield* io.stderr.next(); + } + stderr.close(); + io.stderrDone.resolve(); + }); + + yield* spawn(function* trapError() { + let [error] = yield* once<[Error]>(childProcess, "error"); + processResult.resolve(Err(error)); + }); + + let stdin: Writable = { + send(data: string) { + childProcess.stdin.write(data); + }, + }; + + yield* spawn(function* () { + try { + let value = yield* once(childProcess, "close"); + yield* all([io.stdoutDone.operation, io.stderrDone.operation]); + processResult.resolve(Ok(value)); + } finally { + try { + if (typeof childProcess.pid === "undefined") { + // deno-lint-ignore no-unsafe-finally + throw new Error("no pid for childProcess"); + } + process.kill(-childProcess.pid, "SIGTERM"); + yield* all([io.stdoutDone.operation, io.stderrDone.operation]); + } catch (_e) { + // do nothing, process is probably already dead + } + } + }); + + function* join() { + let result = yield* processResult.operation; + if (result.ok) { + let [code, signal] = result.value; + return { command, options, code, signal } as ExitStatus; + } else { + throw result.error; + } + } + + function* expect() { + let status: ExitStatus = yield* join(); + if (status.code != 0) { + throw new ExecError(status, command, options); + } else { + return status; + } + } + + // FYI: this function starts a process and returns without blocking + return { + pid: pid as number, + stdin, + stdout, + stderr, + join, + expect, + }; +}; diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts new file mode 100644 index 00000000..e733b3be --- /dev/null +++ b/process/src/exec/win32.ts @@ -0,0 +1,212 @@ +import { platform } from "node:os"; +import { + all, + createSignal, + Err, + Ok, + race, + type Result, + sleep, + spawn, + withResolvers, +} from "effection"; +// @ts-types="npm:@types/cross-spawn@6.0.6" +import { spawn as spawnProcess } from "cross-spawn"; +import { ctrlc } from "ctrlc-windows"; +import { once } from "../eventemitter.ts"; +import { useReadable } from "../helpers.ts"; +import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts"; +import { ExecError } from "./error.ts"; + +type ProcessResultValue = [number?, string?]; + +function* killTree(pid: number) { + try { + const killer = spawnProcess( + "cmd.exe", + ["/c", "taskkill", "/PID", String(pid), "/T", "/F"], + { windowsHide: true, stdio: "ignore" }, + ); + yield* once(killer, "close"); + } catch (_) { + // best-effort; ignore errors + } +} + +export const createWin32Process: CreateOSProcess = function* createWin32Process( + command, + options, +) { + let processResult = withResolvers>(); + + // Windows-specific process spawning with different options than POSIX + let childProcess = spawnProcess(command, options.arguments || [], { + // We lose exit information and events if this is detached in windows + // and it opens a window in windows+powershell. + detached: false, + // The `shell` option is passed to `cross-spawn` to control whether a shell is used. + // On Windows, `shell: true` is necessary to run command strings, as it uses + // `cmd.exe` to parse the command and find executables in the PATH. + // Using a boolean `true` was previously disabled, causing ENOENT errors for + // commands that were not a direct path to an executable. + shell: options.shell || false, + // With stdio as pipe, windows gets stuck where neither the child nor the + // parent wants to close the stream, so we call it ourselves in the exit event. + stdio: "pipe", + // Hide the child window so that killing it will not block the parent + // with a Terminate Batch Process (Y/n) + windowsHide: true, + env: options.env, + cwd: options.cwd, + }); + + let { pid } = childProcess; + + let io = { + stdout: yield* useReadable(childProcess.stdout), + stderr: yield* useReadable(childProcess.stderr), + stdoutDone: withResolvers(), + stderrDone: withResolvers(), + }; + + const stdout = createSignal(); + const stderr = createSignal(); + + yield* spawn(function* () { + let next = yield* io.stdout.next(); + while (!next.done) { + stdout.send(next.value); + next = yield* io.stdout.next(); + } + stdout.close(); + io.stdoutDone.resolve(); + }); + + yield* spawn(function* () { + let next = yield* io.stderr.next(); + while (!next.done) { + stderr.send(next.value); + next = yield* io.stderr.next(); + } + stderr.close(); + io.stderrDone.resolve(); + }); + + yield* spawn(function* trapError() { + const [error] = yield* once(childProcess, "error"); + processResult.resolve(Err(error)); + }); + + let stdin: Writable = { + send(data: string) { + childProcess.stdin.write(data); + }, + }; + + yield* spawn(function* () { + try { + let value = yield* once(childProcess, "close"); + yield* all([io.stdoutDone.operation, io.stderrDone.operation]); + processResult.resolve(Ok(value)); + } finally { + try { + // Only try to kill the process if it hasn't exited yet + if ( + childProcess.exitCode === null && + childProcess.signalCode === null + ) { + if (typeof childProcess.pid === "undefined") { + // deno-lint-ignore no-unsafe-finally + throw new Error("no pid for childProcess"); + } + + let stdinStream = childProcess.stdin; + + // Try graceful shutdown with ctrlc + try { + ctrlc(childProcess.pid); + if (stdinStream.writable) { + try { + // Terminate batch process (Y/N) + stdinStream.write("Y\n"); + } catch (_err) { + // not much we can do here + } + } + } catch (_err) { + // ctrlc might fail + } + + // Close stdin to allow process to exit cleanly + try { + stdinStream.end(); + } catch (_err) { + // stdin might already be closed + } + + // Wait for graceful exit with a timeout + yield* race([processResult.operation, sleep(300)]); + + // If process still hasn't exited, escalate + if ( + childProcess.exitCode === null && + childProcess.signalCode === null + ) { + // Try regular kill first + try { + childProcess.kill(); + } catch (_err) { + // process might already be dead + } + + // If still alive after kill, force-kill entire process tree + // This is necessary for bash on Windows where ctrlc doesn't work + // and child.kill() only kills the shell, leaving grandchildren alive + if ( + childProcess.exitCode === null && + childProcess.signalCode === null + ) { + yield* killTree(childProcess.pid); + } + } + + // Wait for streams to finish + yield* all([io.stdoutDone.operation, io.stderrDone.operation]); + } + } catch (_e) { + // do nothing, process is probably already dead + } + } + }); + + function* join() { + let result = yield* processResult.operation; + if (result.ok) { + let [code, signal] = result.value; + return { command, options, code, signal } as ExitStatus; + } else { + throw result.error; + } + } + + function* expect() { + let status = yield* join(); + if (status.code != 0) { + throw new ExecError(status, command, options); + } else { + return status; + } + } + + // FYI: this function starts a process and returns without blocking + return { + pid: pid as number, + stdin, + stdout, + stderr, + join, + expect, + }; +}; + +export const isWin32 = (): boolean => platform() === "win32"; diff --git a/process/src/helpers.ts b/process/src/helpers.ts new file mode 100644 index 00000000..f22b0fa1 --- /dev/null +++ b/process/src/helpers.ts @@ -0,0 +1,95 @@ +import { + createSignal, + Err, + Ok, + type Operation, + resource, + type Result, + type Stream, +} from "effection"; +import type { Readable } from "node:stream"; + +export type OutputStream = Stream; + +export function useReadable( + target: Readable | null, +): Stream { + return resource(function* (provide) { + let signal = createSignal(); + + let listener = (chunk: Uint8Array) => { + signal.send(chunk); + }; + + target?.on("data", listener); + + target?.on("end", signal.close); + + try { + yield* provide(yield* signal); + } finally { + target?.off("data", listener); + target?.off("end", signal.close); + signal.close(); + } + }); +} + +interface Remainder { + remainder: string; + result: T; +} + +export function lines(): ( + stream: Stream, +) => Stream> { + const decoder = new TextDecoder(); + return function (stream) { + return { + *[Symbol.iterator]() { + let subscription = yield* stream; + let buffer: string[] = []; + let remainder = ""; + + return { + *next() { + while (buffer.length === 0) { + let next = yield* subscription.next(); + if (next.done) { + return { + done: true, + value: { + remainder, + result: next.value, + }, + }; + } else { + let current = remainder + decoder.decode(next.value); + let lines = current.split("\n"); + if (lines.length > 0) { + buffer.push(...lines.slice(0, -1)); + remainder = lines.slice(-1)[0]; + } else { + remainder = current; + } + } + } + return { + done: false, + value: buffer.pop()!, + }; + }, + }; + }, + }; + }; +} + +export function* box(op: () => Operation): Operation> { + try { + let value = yield* op(); + return Ok(value); + } catch (e) { + return Err(e as Error); + } +} diff --git a/process/test/daemon.test.ts b/process/test/daemon.test.ts new file mode 100644 index 00000000..e300e520 --- /dev/null +++ b/process/test/daemon.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; +import { spawn, type Task, until, withResolvers } from "effection"; +import process from "node:process"; + +import { type Daemon, daemon } from "../mod.ts"; +import { captureError, expectMatch, fetchText } from "./helpers.ts"; +import { lines } from "../src/helpers.ts"; + +const SystemRoot = Deno.env.get("SystemRoot"); + +describe("daemon", () => { + let task: Task; + let proc: Daemon; + + describe("controlling from outside", () => { + beforeEach(function* () { + const result = withResolvers(); + task = yield* spawn(function* () { + let proc = yield* daemon("deno", { + arguments: ["run", "-A", "./fixtures/echo-server.ts"], + env: { + PORT: "29000", + PATH: process.env.PATH as string, + ...SystemRoot ? { SystemRoot } : {}, + }, + cwd: import.meta.dirname, + }); + result.resolve(proc); + yield* proc; + }); + + proc = yield* result.operation; + + yield* expectMatch(/listening/, lines()(proc.stdout)); + }); + + it("starts the given child", function* () { + const response = yield* fetchText("http://localhost:29000", { + method: "POST", + body: "hello", + }); + + expect(response.status).toEqual(200); + expect(response.text).toEqual("hello"); + }); + + describe("halting the daemon task", () => { + beforeEach(function* () { + yield* until(task.halt()); + }); + it("kills the process", function* () { + expect( + yield* captureError( + fetchText(`http://localhost:29000`, { + method: "POST", + body: "hello", + }), + ), + ).toMatchObject({ + message: expect.stringContaining("FetchError"), + }); + }); + }); + }); + + describe("shutting down the daemon process prematurely", () => { + let task: Task; + beforeEach(function* () { + let proc = yield* daemon("deno", { + arguments: ["run", "-A", "./fixtures/echo-server.ts"], + env: { + PORT: "29000", + PATH: process.env.PATH as string, + ...SystemRoot ? { SystemRoot } : {}, + }, + cwd: import.meta.dirname, + }); + + task = yield* spawn(function* () { + try { + yield* proc; + } catch (e) { + return e as Error; + } + return new Error(`this shouldn't happen`); + }); + + yield* expectMatch(/listening/, lines()(proc.stdout)); + + yield* fetchText("http://localhost:29000", { + method: "POST", + body: "exit", + }); + }); + + it("throw an error because it was not expected to close", function* () { + yield* until( + expect(task).resolves.toHaveProperty("name", "DaemonExitError"), + ); + }); + }); +}); diff --git a/process/test/eventemitter.test.ts b/process/test/eventemitter.test.ts new file mode 100644 index 00000000..985d525f --- /dev/null +++ b/process/test/eventemitter.test.ts @@ -0,0 +1,66 @@ +import { expect } from "@std/expect"; +import { spawn } from "effection"; +import { describe, it } from "@effectionx/bdd"; +import { EventEmitter } from "node:events"; + +import { once } from "../src/eventemitter.ts"; + +describe("once", () => { + it("resolves with single argument as array", function* () { + expect.assertions(1); + const emitter = new EventEmitter(); + + let result; + yield* spawn(function* () { + result = yield* once<[string]>(emitter, "test"); + }); + + emitter.emit("test", "hello"); + + expect(result).toEqual(["hello"]); + }); + + it("resolves with multiple arguments as array", function* () { + expect.assertions(1); + const emitter = new EventEmitter(); + + let result; + + yield* spawn(function* () { + result = yield* once<[number, string]>(emitter, "exit"); + }); + + emitter.emit("exit", 42, "SIGTERM"); + + expect(result).toEqual([42, "SIGTERM"]); + }); + + it("only resolves once even with multiple emissions", function* () { + const emitter = new EventEmitter(); + + let first; + yield* spawn(function* () { + first = yield* once<[string]>(emitter, "data"); + }); + + emitter.emit("data", "first"); + emitter.emit("data", "second"); + + expect(first).toEqual(["first"]); + }); + + it("removes listener after resolving", function* () { + expect.assertions(2); + const emitter = new EventEmitter(); + + yield* spawn(function* () { + yield* once<[string]>(emitter, "test"); + }); + + expect(emitter.listenerCount("test")).toBe(1); + + emitter.emit("test", "hello"); + + expect(emitter.listenerCount("test")).toBe(0); + }); +}); diff --git a/process/test/exec.test.ts b/process/test/exec.test.ts new file mode 100644 index 00000000..113a6549 --- /dev/null +++ b/process/test/exec.test.ts @@ -0,0 +1,479 @@ +import { spawn, type Task } from "effection"; +import { expect } from "@std/expect"; +import { beforeEach, describe, it } from "@effectionx/bdd"; +import process from "node:process"; + +import { + captureError, + expectMatch, + fetchText, + streamClose, +} from "./helpers.ts"; + +import { exec, type Process, type ProcessResult } from "../mod.ts"; +import { lines } from "../src/helpers.ts"; + +const SystemRoot = Deno.env.get("SystemRoot"); + +// Validate SHELL environment variable is set for proper test execution +if (process.platform === "win32" && !process.env.SHELL) { + throw new Error( + "SHELL environment variable is required for Windows tests.\n" + + "Please set SHELL using one of these commands:\n" + + " PowerShell: $env:SHELL = 'powershell'\n" + + " pwsh: $env:SHELL = 'pwsh'\n" + + " CMD: set SHELL=cmd\n" + + " Git Bash: export SHELL=bash", + ); +} + +const isBash = () => { + // On POSIX systems, SHELL is undefined so default to bash + if (process.platform !== "win32") return true; + + // On Windows, SHELL is required and set, check it or fallback to process.env.shell + const shell = process.env.SHELL?.toLowerCase(); + const processShell = process.env.shell; + + return shell === "bash" || processShell?.endsWith("bash.exe"); +}; + +describe("exec", () => { + describe(".join", () => { + it("runs successfully to completion", function* () { + let result: ProcessResult = yield* exec( + "node './fixtures/hello-world.js'", + { + cwd: import.meta.dirname, + }, + ).join(); + + expect(result).toMatchObject({ + code: 0, + stdout: "hello\nworld\n", + stderr: "boom\n", + }); + }); + + it("runs failed process to completion", function* () { + let result: ProcessResult = yield* exec( + "node './fixtures/hello-world-failed.js'", + { + cwd: import.meta.dirname, + }, + ).join(); + + expect(result.code).toEqual(37); + expect(result.stdout).toEqual("hello world\n"); + expect(result.stderr).toEqual("boom\n"); + }); + }); + + describe(".expect", () => { + expect.assertions(1); + it("runs successfully to completion", function* () { + let result: ProcessResult = yield* exec( + "node './fixtures/hello-world.js'", + { + cwd: import.meta.dirname, + }, + ).expect(); + + expect(result).toMatchObject({ + code: 0, + stdout: "hello\nworld\n", + stderr: "boom\n", + }); + }); + + it("throws an error if process fails", function* () { + let error: Error = yield* captureError( + exec("node './test/fixtures/hello-world-failed.js'").expect(), + ); + + expect(error.name).toEqual("ExecError"); + }); + }); + + describe("spawning", () => { + describe("a process that fails to start because executable is not found", () => { + it("calling join() throws an exception", function* () { + let error: unknown; + let proc = yield* exec("argle", { arguments: ["bargle"] }); + try { + yield* proc.join(); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(Error); + }); + it("calling expect() throws an exception", function* () { + let error: unknown; + let proc = yield* exec("argle", { arguments: ["bargle"] }); + try { + yield* proc.expect(); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + }); + }); + }); + describe("successfully", () => { + let proc: Process; + let joinStdout: Task; + let joinStderr: Task; + + beforeEach(function* () { + proc = yield* exec("deno run -A './fixtures/echo-server.ts'", { + env: { + PORT: "29000", + PATH: process.env.PATH as string, + ...(SystemRoot ? { SystemRoot } : {}), + }, + cwd: import.meta.dirname, + }); + + joinStdout = yield* spawn(streamClose(proc.stdout)); + joinStderr = yield* spawn(streamClose(proc.stderr)); + + yield* expectMatch(/listening/, lines()(proc.stdout)); + }); + + describe("when it succeeds", () => { + beforeEach(function* () { + yield* fetchText("http://localhost:29000", { + method: "POST", + body: "exit", + }); + }); + + it("has a pid", function* () { + expect(typeof proc.pid).toBe("number"); + expect(proc.pid).not.toBeNaN(); + // make sure it completes + yield* proc.join(); + }); + + it("joins successfully", function* () { + let status = yield* proc.join(); + expect(status.code).toEqual(0); + }); + + it("expects successfully", function* () { + let status = yield* proc.expect(); + expect(status.code).toEqual(0); + }); + + it("closes stdout and stderr", function* () { + expect.assertions(2); + yield* proc.expect(); + expect(yield* joinStdout).toEqual(undefined); + expect(yield* joinStderr).toEqual(undefined); + }); + }); + + describe("when it fails", () => { + let error: Error; + beforeEach(function* () { + yield* fetchText("http://localhost:29000", { + method: "POST", + body: "fail", + }); + }); + + it("joins successfully", function* () { + let status = yield* proc.join(); + expect(status.code).not.toEqual(0); + }); + + it("expects unsuccessfully", function* () { + try { + yield* proc.expect(); + } catch (e) { + error = e as Error; + } + expect(error).toBeDefined(); + }); + + it("closes stdout and stderr", function* () { + expect(yield* joinStdout).toEqual(undefined); + expect(yield* joinStderr).toEqual(undefined); + }); + }); + }); +}); + +// running shell scripts in windows is not well supported, our windows +// process stuff sets shell to `false` and so you probably shouldn't do this +// in windows at all. +if (process.platform !== "win32") { + describe("when the `shell` option is true", () => { + it("lets the shell do all of the shellword parsing", function* () { + let proc = exec('echo "first" | echo "second"', { + shell: true, + }); + let { stdout }: ProcessResult = yield* proc.expect(); + + expect(stdout).toEqual("second\n"); + }); + }); +} + +describe("when the `shell` option is `false`", () => { + it("correctly receives literal arguments when shell: false", function* () { + // Arguments are passed literally as parsed by shellwords + let proc = exec(`deno run -A ./fixtures/dump-args.js first | echo second`, { + shell: false, + cwd: import.meta.dirname, + }); + let { stdout }: ProcessResult = yield* proc.expect(); + + // Node's console.log uses a single LF (\n) line ending. + const expected = JSON.stringify({ + args: ["first", "|", "echo", "second"], // Arguments received by Node + envVar: undefined, + }) + "\n"; + + expect(stdout).toEqual(expected); + }); + + it("verifies environment variable handling and literal argument passing", function* () { + // Execute the custom script with the literal argument + let proc = exec( + `deno run -A ./fixtures/dump-args.js $EFFECTION_TEST_ENV_VAL`, + { + shell: false, // Ensures the argument is passed literally + env: { EFFECTION_TEST_ENV_VAL: "boop" }, // Sets the environment variable + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + // The argument is passed literally, and the env var is available in the child process's env. + const expected = JSON.stringify({ + args: ["$EFFECTION_TEST_ENV_VAL"], // Argument is passed literally + envVar: "boop", // Env variable is read from process.env + }) + "\n"; + + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); +}); + +describe("handles env vars", () => { + describe("when the `shell` option is `bash`", () => { + let shell = "bash"; + + it("can pass in an environment variable", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js $EFFECTION_TEST_ENV_VAL", + { + shell, + env: { + EFFECTION_TEST_ENV_VAL: "boop", + PATH: process.env.PATH as string, + }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + const expected = JSON.stringify({ + args: ["boop"], + envVar: "boop", + }) + "\n"; + + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + + it("can pass in an environment variable with curly brace syntax", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js ${EFFECTION_TEST_ENV_VAL}", + { + shell, + env: { + EFFECTION_TEST_ENV_VAL: "boop", + PATH: process.env.PATH as string, + }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + const expected = JSON.stringify({ + args: ["boop"], + envVar: "boop", + }) + "\n"; + + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + }); + + describe("when the `shell` option is `true`", () => { + let shell = true; + + it("can pass in an environment variable", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js $EFFECTION_TEST_ENV_VAL", + { + shell, + env: { + EFFECTION_TEST_ENV_VAL: "boop", + PATH: process.env.PATH as string, + }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + // this fails on windows, this shell option doesn't work on windows + // due to it generally running through cmd.exe which can't handle this syntax + let expected = process.platform !== "win32" + ? JSON.stringify({ args: ["boop"], envVar: "boop" }) + "\n" + // note the additional \r that is added + : JSON.stringify({ + args: ["$EFFECTION_TEST_ENV_VAL"], + envVar: "boop", + }) + "\n"; + + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + + it("can pass in an environment variable with curly brace syntax", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js ${EFFECTION_TEST_ENV_VAL}", + { + shell, + env: { + EFFECTION_TEST_ENV_VAL: "boop", + PATH: process.env.PATH as string, + }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + // this fails on windows, this shell option doesn't work on windows + // due to it generally running through cmd.exe which can't handle this syntax + let expected = process.platform !== "win32" + ? JSON.stringify({ args: ["boop"], envVar: "boop" }) + "\n" + // note the additional \r that is added + : JSON.stringify({ + args: ["${EFFECTION_TEST_ENV_VAL}"], + envVar: "boop", + }) + "\n"; + + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + }); + + describe("when the `shell` option is `false`", () => { + let shell = false; + + it("can pass in an environment variable", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js $EFFECTION_TEST_ENV_VAL", + { + shell, + env: { EFFECTION_TEST_ENV_VAL: "boop" }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + const expected = JSON.stringify({ + args: ["$EFFECTION_TEST_ENV_VAL"], + envVar: "boop", + }) + "\n"; + + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + + it("can pass in an environment variable with curly brace syntax", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js ${EFFECTION_TEST_ENV_VAL}", + { + shell, + env: { EFFECTION_TEST_ENV_VAL: "boop" }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + // Platform behavior differences with shell: false: + // - PowerShell/CMD: Preserves quotes around arguments and keeps curly braces: "${EFFECTION_TEST_ENV_VAL}" + CRLF + // - Bash (Windows): Normalizes ${VAR} to $VAR during argument processing: $EFFECTION_TEST_ENV_VAL + LF + // - Bash (Unix): Keeps curly braces intact: ${EFFECTION_TEST_ENV_VAL} + LF + // Note: Shellwords parsing preserves braces on all platforms, but bash execution normalizes them + const expected = JSON.stringify({ + args: ["${EFFECTION_TEST_ENV_VAL}"], + envVar: "boop", + }) + "\n"; + + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + }); + + if (process.platform === "win32" && isBash()) { + describe("when the `shell` option is `process.env.shell` (Windows bash only)", () => { + let shell = process.env.shell; + // This tests Git Bash on Windows where process.env.shell is set to bash.exe + + it("can pass in an environment variable", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js $EFFECTION_TEST_ENV_VAL", + { + shell, + env: { + EFFECTION_TEST_ENV_VAL: "boop", + PATH: process.env.PATH as string, + }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + // Windows bash should resolve environment variables + const expected = JSON.stringify({ + args: ["boop"], + envVar: "boop", + }) + "\n"; + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + + it("can pass in an environment variable with curly brace syntax", function* () { + let proc = exec( + "deno run -A ./fixtures/dump-args.js ${EFFECTION_TEST_ENV_VAL}", + { + shell, + env: { + EFFECTION_TEST_ENV_VAL: "boop", + PATH: process.env.PATH as string, + }, + cwd: import.meta.dirname, + }, + ); + let { stdout, code }: ProcessResult = yield* proc.expect(); + + // Windows bash should resolve environment variables with curly brace syntax + const expected = JSON.stringify({ + args: ["boop"], + envVar: "boop", + }) + "\n"; + expect(stdout).toEqual(expected); + expect(code).toBe(0); + }); + }); + } + + // Close the main "handles env vars" describe block +}); diff --git a/process/test/fixtures/dump-args.js b/process/test/fixtures/dump-args.js new file mode 100644 index 00000000..909363aa --- /dev/null +++ b/process/test/fixtures/dump-args.js @@ -0,0 +1,13 @@ +import process from "node:process"; + +// This script is executed via Node: 'node dump-args.js arg1 arg2 ...' +// process.argv[0] is the node executable path +// process.argv[1] is the path to this script (dump-args.js) +// process.argv[2] and onwards are the arguments passed from your 'exec' call. +const args = process.argv.slice(2); + +// Look up the specific environment variable +const envVar = process.env.EFFECTION_TEST_ENV_VAL; + +// Print a stable, predictable output string with a Unix line ending (\n) +console.log(JSON.stringify({ args, envVar })); diff --git a/process/test/fixtures/echo-server.ts b/process/test/fixtures/echo-server.ts new file mode 100644 index 00000000..4ceb03b7 --- /dev/null +++ b/process/test/fixtures/echo-server.ts @@ -0,0 +1,44 @@ +const { process } = globalThis; + +console.log("starting server"); +Deno.serve({ + port: parseInt(process.env.PORT || "29000"), + onListen: () => { + console.log("listening"); + }, +}, async (request: Request) => { + process.stderr.write(`got request\n`); + + // Read the entire request body + const command = await request.text(); + + // Handle the command asynchronously to allow response to be sent first + setTimeout(() => { + readCommand(command); + }, 100); + + // Echo the request body back + return new Response(command, { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); +}); + +process.on("message", function (message: unknown) { + console.log("got message", message); + if (process.send) { + process.send(message); + } +}); + +function readCommand(command: string) { + if (command.includes("exit")) { + console.log("exit(0)"); + process.exit(0); + } else if (command.includes("fail")) { + console.log("exit(1)"); + process.exit(1); + } +} + +process.on("exit", () => console.log("exiting...")); diff --git a/process/test/fixtures/hello-world-failed.js b/process/test/fixtures/hello-world-failed.js new file mode 100644 index 00000000..8617cb11 --- /dev/null +++ b/process/test/fixtures/hello-world-failed.js @@ -0,0 +1,5 @@ +const { process } = globalThis; + +process.stdout.write("hello world\n"); +process.stderr.write("boom\n"); +process.exit(37); diff --git a/process/test/fixtures/hello-world.js b/process/test/fixtures/hello-world.js new file mode 100644 index 00000000..0de604d3 --- /dev/null +++ b/process/test/fixtures/hello-world.js @@ -0,0 +1,5 @@ +const { process } = globalThis; + +process.stdout.write("hello\n"); +process.stdout.write("world\n"); +process.stderr.write("boom\n"); diff --git a/process/test/helpers.ts b/process/test/helpers.ts new file mode 100644 index 00000000..7e3d9057 --- /dev/null +++ b/process/test/helpers.ts @@ -0,0 +1,63 @@ +import { type Operation, type Stream, until } from "effection"; +import { filter } from "@effectionx/stream-helpers"; + +export function* captureError(op: Operation): Operation { + try { + yield* op; + } catch (error) { + return error as Error; + } + throw new Error("expected operation to throw an error, but it did not!"); +} + +export function expectStreamNotEmpty( + stream: Stream, +): Operation { + return { + *[Symbol.iterator]() { + const subscription = yield* stream; + let next = yield* subscription.next(); + if (next.done) { + throw new Error( + `Expected the stream to produce at least one value before closing.`, + ); + } + }, + }; +} + +export function* fetchText(input: RequestInfo | URL, init?: RequestInit) { + try { + const response = yield* until(globalThis.fetch(input, init)); + if (!response.ok) { + throw new Error(response.statusText); + } + return { + status: response.status, + text: yield* until(response.text()), + }; + } catch (e) { + throw new Error(`FetchError: ${(e as Error).message}`); + } +} + +export function streamClose( + stream: Stream, +): () => Operation { + return function* () { + const subscription = yield* stream; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + return next.value; + }; +} + +export function* expectMatch(pattern: RegExp, stream: Stream) { + const stdout = filter(function* (v) { + return pattern.test(v); + })(stream); + + yield* expectStreamNotEmpty(stdout); +} diff --git a/process/test/output-stream.test.ts b/process/test/output-stream.test.ts new file mode 100644 index 00000000..876a8b8e --- /dev/null +++ b/process/test/output-stream.test.ts @@ -0,0 +1,73 @@ +// import { expect } from "@std/expect"; +// import { createSignal, each, spawn } from "effection"; +// import { describe, it } from "@effectionx/bdd"; + +// import { createOutputStream } from "../src/output-stream.ts"; +// import { Buffer } from "node:buffer"; + +// const b = (value: string) => Buffer.from(value, "utf8"); + +// describe("createOutputStream", () => { +// it("can be created from regular stream", function* () { +// const stream = createSignal(); + +// let ioStream = createOutputStream(stream); +// let values: Buffer[] = []; + +// yield* spawn(function* () { +// for (const value of yield* each(ioStream)) { +// values.push(value); +// yield* each.next(); +// } +// }); + +// stream.send(b("foo")); +// stream.send(b("bar")); +// stream.send(b("baz")); + +// expect(values).toEqual([b("foo"), b("bar"), b("baz")]); +// }); +// }); + +// describe("text()", () => { +// it("maps output to string", function* () { +// const stream = createSignal(); +// let ioStream = createOutputStream(stream); +// let values: string[] = []; + +// yield* spawn(function* () { +// for (const value of yield* each(ioStream.text())) { +// values.push(value); +// yield* each.next(); +// } +// }); + +// stream.send(b("foo")); +// stream.send(b("bar")); +// stream.send(b("baz")); + +// expect(values).toEqual(["foo", "bar", "baz"]); +// }); +// }); + +// describe("lines()", () => { +// it("combines output into complete lines", function* () { +// const stream = createSignal(); +// let ioStream = createOutputStream(stream); +// let values: string[] = []; + +// yield* spawn(function* () { +// for (const value of yield* each(ioStream.lines())) { +// values.push(value); +// yield* each.next(); +// } +// }); + +// stream.send(b("foo\nhello")); +// stream.send(b("world\n")); +// stream.send(b("something")); +// stream.close(); + +// expect(values).toEqual(["foo", "helloworld", "something"]); +// }); +// }); diff --git a/raf/deno.json b/raf/deno.json index e5e059c9..a1d96d04 100644 --- a/raf/deno.json +++ b/raf/deno.json @@ -1,10 +1,12 @@ { "name": "@effectionx/raf", - "version": "1.0.0-alpha.1", + "version": "0.0.1", "exports": "./mod.ts", "license": "MIT", "imports": { "effection": "npm:effection@^3", - "@essentials/raf": "npm:@essentials/raf@^1.2.0" + "@essentials/raf": "npm:@essentials/raf@^1.2.0", + "@std/expect": "jsr:@std/expect@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2" } } diff --git a/raf/raf.test.ts b/raf/raf.test.ts index 0ab87458..19f137f7 100644 --- a/raf/raf.test.ts +++ b/raf/raf.test.ts @@ -1,11 +1,11 @@ +import { describe, it } from "@effectionx/bdd"; import { caf as cancelAnimationFrame, raf as requestAnimationFrame, } from "@essentials/raf"; +import { expect } from "@std/expect"; +import { each, sleep, spawn } from "effection"; -import { each, run, sleep, spawn } from "effection"; -import { describe, it } from "bdd"; -import { expect } from "expect"; import { raf } from "./raf.ts"; Object.assign(globalThis, { @@ -14,19 +14,16 @@ Object.assign(globalThis, { }); describe("raf", () => { - it("subscription", async () => { + it("subscription", function* () { expect.assertions(1); let count = 0; - await run(function* () { - yield* spawn(function* () { - for (const _ of yield* each(raf)) { - count++; - yield* each.next(); - } - }); - yield* sleep(100); + yield* spawn(function* () { + for (const _ of yield* each(raf)) { + count++; + yield* each.next(); + } }); - + yield* sleep(100); expect(count > 5).toBe(true); }); }); diff --git a/signals/array.test.ts b/signals/array.test.ts index b060ab9d..06b580f5 100644 --- a/signals/array.test.ts +++ b/signals/array.test.ts @@ -1,113 +1,92 @@ +import { describe, it } from "@effectionx/bdd"; import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; -import { each, run, sleep, spawn } from "effection"; +import { each, sleep, spawn } from "effection"; import { createArraySignal } from "./array.ts"; describe("array signal", () => { - it("accepts an initial value", async () => { - expect.assertions(1); - await run(function* () { - const array = yield* createArraySignal([1, 2, 3]); + it("accepts an initial value", function* () { + const array = yield* createArraySignal([1, 2, 3]); - expect(array.valueOf()).toEqual([1, 2, 3]); - }); + expect(array.valueOf()).toEqual([1, 2, 3]); }); describe("set", () => { - it("allows to set a new value", async () => { - expect.assertions(1); - await run(function* () { - const array = yield* createArraySignal([1, 2, 3]); + it("allows to set a new value", function* () { + const array = yield* createArraySignal([1, 2, 3]); - array.set([4, 5, 6]); + array.set([4, 5, 6]); - expect(array.valueOf()).toEqual([4, 5, 6]); - }); + expect(array.valueOf()).toEqual([4, 5, 6]); }); - it("does not send a value to the stream when the set value is the same as the current value", async () => { - expect.assertions(2); - await run(function* () { - const array = yield* createArraySignal([]); + it("does not send a value to the stream when the set value is the same as the current value", function* () { + const array = yield* createArraySignal([]); - const updates: number[][] = []; + const updates: number[][] = []; - yield* spawn(function* () { - for (const update of yield* each(array)) { - updates.push(update); - yield* each.next(); - } - }); + yield* spawn(function* () { + for (const update of yield* each(array)) { + updates.push(update); + yield* each.next(); + } + }); - array.set([1, 2, 3]); + array.set([1, 2, 3]); - expect(updates).toEqual([[1, 2, 3]]); + expect(updates).toEqual([[1, 2, 3]]); - array.set([1, 2, 3]); + array.set([1, 2, 3]); - expect(updates).toEqual([[1, 2, 3]]); - }); + expect(updates).toEqual([[1, 2, 3]]); }); }); - it("allows to push a new value", async () => { - expect.assertions(1); - await run(function* () { - const array = yield* createArraySignal([1, 2, 3]); + it("allows to push a new value", function* () { + const array = yield* createArraySignal([1, 2, 3]); - array.push(4); + array.push(4); - expect(array.valueOf()).toEqual([1, 2, 3, 4]); - }); + expect(array.valueOf()).toEqual([1, 2, 3, 4]); }); describe("shift", () => { - it("returns the first value", async () => { - expect.assertions(2); - await run(function* () { - const array = yield* createArraySignal([1, 2, 3]); + it("returns the first value", function* () { + const array = yield* createArraySignal([1, 2, 3]); - const value = yield* array.shift(); + const value = yield* array.shift(); - expect(value).toEqual(1); - expect(array.valueOf()).toEqual([2, 3]); - }); + expect(value).toEqual(1); + expect(array.valueOf()).toEqual([2, 3]); }); - it("blocks until a value is available", async () => { - expect.assertions(2); - await run(function* () { - const ops: string[] = []; - const array = yield* createArraySignal([]); - - yield* spawn(function* () { - ops.push("before shift"); - const value = yield* array.shift(); - ops.push(`got ${value}`); - ops.push("after shift"); - return value; - }); - - array.push(1); - yield* sleep(1); - - expect(ops).toEqual(["before shift", "got 1", "after shift"]); - expect(array.valueOf()).toEqual([]); + it("blocks until a value is available", function* () { + const ops: string[] = []; + const array = yield* createArraySignal([]); + + yield* spawn(function* () { + ops.push("before shift"); + const value = yield* array.shift(); + ops.push(`got ${value}`); + ops.push("after shift"); + return value; }); + + array.push(1); + yield* sleep(1); + + expect(ops).toEqual(["before shift", "got 1", "after shift"]); + expect(array.valueOf()).toEqual([]); }); }); describe("update", () => { - it("updates the value of the signal", async () => { - expect.assertions(1); - await run(function* () { - const array = yield* createArraySignal([1, 2, 3]); + it("updates the value of the signal", function* () { + const array = yield* createArraySignal([1, 2, 3]); - array.update((array) => array.map((x) => x + 1)); + array.update((array) => array.map((x) => x + 1)); - expect(array.valueOf()).toEqual([2, 3, 4]); - }); + expect(array.valueOf()).toEqual([2, 3, 4]); }); }); }); diff --git a/signals/array.ts b/signals/array.ts index 0d5c04ce..477cc04b 100644 --- a/signals/array.ts +++ b/signals/array.ts @@ -14,7 +14,7 @@ export interface ArraySignal extends ValueSignal { * @param item - The value to push onto the array. * @returns The new length of the array. */ - push(item: T): number; + push(...args: T[]): number; /** * Removes the first value from the array and returns it. * If the array is empty, the operation will block until a value is available. diff --git a/signals/boolean.test.ts b/signals/boolean.test.ts index 3931c81c..0008d494 100644 --- a/signals/boolean.test.ts +++ b/signals/boolean.test.ts @@ -1,62 +1,50 @@ -import { each, run, spawn } from "effection"; -import { describe, it } from "@std/testing/bdd"; +import { each, spawn } from "effection"; +import { describe, it } from "@effectionx/bdd"; import { expect } from "@std/expect"; import { createBooleanSignal } from "./boolean.ts"; describe("boolean", () => { - it("takes an initial value", async () => { - expect.assertions(1); - await run(function* () { - const boolean = yield* createBooleanSignal(true); + it("takes an initial value", function* () { + const boolean = yield* createBooleanSignal(true); - expect(boolean.valueOf()).toEqual(true); - }); + expect(boolean.valueOf()).toEqual(true); }); describe("set", () => { - it("allows to set a new value", async () => { - expect.assertions(1); - await run(function* () { - const boolean = yield* createBooleanSignal(true); + it("allows to set a new value", function* () { + const boolean = yield* createBooleanSignal(true); - boolean.set(false); + boolean.set(false); - expect(boolean.valueOf()).toEqual(false); - }); + expect(boolean.valueOf()).toEqual(false); }); - it("does not send a value to the stream when the set value is the same as the current value", async () => { - expect.assertions(2); - await run(function* () { - const boolean = yield* createBooleanSignal(true); + it("does not send a value to the stream when the set value is the same as the current value", function* () { + const boolean = yield* createBooleanSignal(true); - const updates: boolean[] = []; + const updates: boolean[] = []; - yield* spawn(function* () { - for (const update of yield* each(boolean)) { - updates.push(update); - yield* each.next(); - } - }); + yield* spawn(function* () { + for (const update of yield* each(boolean)) { + updates.push(update); + yield* each.next(); + } + }); - boolean.set(true); + boolean.set(true); - expect(updates).toEqual([]); + expect(updates).toEqual([]); - boolean.set(false); + boolean.set(false); - expect(updates).toEqual([false]); - }); + expect(updates).toEqual([false]); }); }); describe("update", () => { - it("updates the value of the signal", async () => { - expect.assertions(1); - await run(function* () { - const boolean = yield* createBooleanSignal(true); + it("updates the value of the signal", function* () { + const boolean = yield* createBooleanSignal(true); - boolean.update((boolean) => !boolean); + boolean.update((boolean) => !boolean); - expect(boolean.valueOf()).toEqual(false); - }); + expect(boolean.valueOf()).toEqual(false); }); }); }); diff --git a/signals/deno.json b/signals/deno.json index ed8f66e0..2b967143 100644 --- a/signals/deno.json +++ b/signals/deno.json @@ -4,8 +4,8 @@ "imports": { "effection": "npm:effection@^3", "immutable": "npm:immutable@^5", - "@std/testing": "jsr:@std/testing@^1", - "@std/expect": "jsr:@std/expect@^1" + "@std/expect": "jsr:@std/expect@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2" }, "license": "MIT", "exports": { diff --git a/signals/helpers.test.ts b/signals/helpers.test.ts index f001794f..8fb7076d 100644 --- a/signals/helpers.test.ts +++ b/signals/helpers.test.ts @@ -1,25 +1,22 @@ +import { describe, it } from "@effectionx/bdd"; import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; -import { run, spawn } from "effection"; +import { spawn } from "effection"; import { createBooleanSignal } from "./boolean.ts"; import { is } from "./helpers.ts"; describe("is", () => { - it("waits until the value of the stream matches the predicate", async () => { - expect.assertions(1); - await run(function* () { - const open = yield* createBooleanSignal(false); - const update: string[] = []; + it("waits until the value of the stream matches the predicate", function* () { + const open = yield* createBooleanSignal(false); + const update: string[] = []; - yield* spawn(function* () { - yield* is(open, (open) => open === true); - update.push("floodgates are open!"); - }); + yield* spawn(function* () { + yield* is(open, (open) => open === true); + update.push("floodgates are open!"); + }); - open.set(true); + open.set(true); - expect(update).toEqual(["floodgates are open!"]); - }); + expect(update).toEqual(["floodgates are open!"]); }); }); diff --git a/signals/set.test.ts b/signals/set.test.ts index ed669a37..7f34842c 100644 --- a/signals/set.test.ts +++ b/signals/set.test.ts @@ -1,104 +1,76 @@ -import { describe, it } from "@std/testing/bdd"; +import { describe, it } from "@effectionx/bdd"; import { expect } from "@std/expect"; -import { each, run, spawn } from "effection"; +import { each, spawn } from "effection"; import { createSetSignal } from "./set.ts"; import { Set } from "immutable"; describe("createSetSignal", () => { - it("should create a set signal", async () => { - expect.assertions(1); + it("should create a set signal", function* () { + const set = yield* createSetSignal([]); - await run(function* () { - const set = yield* createSetSignal([]); + set.add(1); - set.add(1); - - expect(set.valueOf().toArray()).toEqual([1]); - }); + expect(set.valueOf().toArray()).toEqual([1]); }); describe("set", () => { - it("should set the value of the set", async () => { - expect.assertions(1); + it("should set the value of the set", function* () { + const set = yield* createSetSignal([1, 2, 3]); - await run(function* () { - const set = yield* createSetSignal([1, 2, 3]); + set.set(Set.of(4, 5, 6)); - set.set(Set.of(4, 5, 6)); - - expect(set.valueOf().toArray()).toEqual([4, 5, 6]); - }); + expect(set.valueOf().toArray()).toEqual([4, 5, 6]); }); - it("should not update if the set is the same", async () => { - expect.assertions(1); + it("should not update if the set is the same", function* () { + const set = yield* createSetSignal([1, 2, 3]); - await run(function* () { - const set = yield* createSetSignal([1, 2, 3]); + const updates: number[][] = []; - const updates: number[][] = []; - - yield* spawn(function* () { - for (const update of yield* each(set)) { - updates.push(update.toArray()); - yield* each.next(); - } - }); + yield* spawn(function* () { + for (const update of yield* each(set)) { + updates.push(update.toArray()); + yield* each.next(); + } + }); - set.set(Set.of(1, 2, 3)); + set.set(Set.of(1, 2, 3)); - expect(updates).toEqual([]); - }); + expect(updates).toEqual([]); }); }); describe("difference", () => { - it("should return a new set with the items that are in the current set but not in the given iterable", async () => { - expect.assertions(1); - - await run(function* () { - const set = yield* createSetSignal([1, 2, 3]); + it("should return a new set with the items that are in the current set but not in the given iterable", function* () { + const set = yield* createSetSignal([1, 2, 3]); - set.difference([2, 3, 4]); + set.difference([2, 3, 4]); - expect(set.valueOf().toArray()).toEqual([1]); - }); + expect(set.valueOf().toArray()).toEqual([1]); }); }); describe("delete", () => { - it("should remove an item from the set", async () => { - expect.assertions(1); + it("should remove an item from the set", function* () { + const set = yield* createSetSignal([1, 2, 3]); - await run(function* () { - const set = yield* createSetSignal([1, 2, 3]); + set.delete(2); - set.delete(2); - - expect(set.valueOf().toArray()).toEqual([1, 3]); - }); + expect(set.valueOf().toArray()).toEqual([1, 3]); }); }); describe("add", () => { - it("should add an item to the set", async () => { - expect.assertions(1); + it("should add an item to the set", function* () { + const set = yield* createSetSignal([1, 2, 3]); - await run(function* () { - const set = yield* createSetSignal([1, 2, 3]); + set.add(4); - set.add(4); - - expect(set.valueOf().toArray()).toEqual([1, 2, 3, 4]); - }); + expect(set.valueOf().toArray()).toEqual([1, 2, 3, 4]); }); }); describe("update", () => { - it("updates the value of the signal", async () => { - expect.assertions(1); + it("updates the value of the signal", function* () { + const set = yield* createSetSignal([1, 2, 3]); - await run(function* () { - const set = yield* createSetSignal([1, 2, 3]); + set.update((set) => set.add(4)); - set.update((set) => set.add(4)); - - expect(set.valueOf().toArray()).toEqual([1, 2, 3, 4]); - }); + expect(set.valueOf().toArray()).toEqual([1, 2, 3, 4]); }); }); }); diff --git a/stream-helpers/README.md b/stream-helpers/README.md index 3b6d8766..86303cd7 100644 --- a/stream-helpers/README.md +++ b/stream-helpers/README.md @@ -144,6 +144,54 @@ function* example() { } ``` +### ForEach + +The `forEach` helper invokes a function for each item passing through a stream. +This is useful when you need to perform side effects or operations on each item +without transforming the stream itself. Unlike other stream helpers that return +transformed streams, `forEach` consumes the entire stream and returns the +stream's close value. + +```typescript +import { forEach } from "@effectionx/stream-helpers"; +import { createSignal, spawn } from "effection"; + +function* example() { + const stream = createSignal(); + + // Process each item in the stream + yield* spawn(() => + forEach(function* (item) { + console.log(`Processing: ${item}`); + // Perform any side effects here + }, stream) + ); + + stream.send(1); + stream.send(2); + stream.send(3); + stream.close(); +} + +// Example: Handling stream close value +function* exampleWithCloseValue() { + const stream = createSignal(); + + const result = yield* spawn(() => + forEach(function* (item) { + console.log(`Item: ${item}`); + }, stream) + ); + + stream.send("hello"); + stream.send("world"); + stream.close(42); // Close with value 42 + + const closeValue = yield* result; + console.log(`Stream closed with: ${closeValue}`); // 42 +} +``` + ### Passthrough Tracker Passthrough Tracker stream helper provides a way to know if all items that @@ -183,8 +231,7 @@ You can use a simple `pipe()` to compose a series of stream helpers together. In this example, we use one from [remeda](https://remedajs.com/docs/#pipe), ```typescript -import { batch, filter, map, valve } from "@effectionx/stream-helpers"; -import { each } from "effection"; +import { batch, filter, forEach, map, valve } from "@effectionx/stream-helpers"; // any standard pipe function should work import { pipe } from "remeda"; @@ -202,10 +249,10 @@ function* example(source: Stream) { batch({ maxSize: 50 }), ); - for (const value of yield* each(stream)) { + // Use forEach to process each value in the composed stream + yield* forEach(function* (value) { console.log(value); - yield* each.next(); - } + }, stream); } ``` diff --git a/stream-helpers/batch.test.ts b/stream-helpers/batch.test.ts index db620846..fbf35949 100644 --- a/stream-helpers/batch.test.ts +++ b/stream-helpers/batch.test.ts @@ -1,109 +1,91 @@ -import { each, run, sleep, spawn, withResolvers } from "effection"; -import { describe, it } from "@std/testing/bdd"; +import { describe, it } from "@effectionx/bdd"; +import { createArraySignal, is } from "@effectionx/signals"; import { expect } from "@std/expect"; +import { sleep, spawn } from "effection"; + import { batch } from "./batch.ts"; +import { forEach } from "./for-each.ts"; import { useFaucet } from "./test-helpers/faucet.ts"; describe("batch", () => { - it("creates a batch when maxTime expires", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = batch({ maxTime: 5 })(faucet); - - const subscription = yield* stream; - - yield* faucet.pour(function* (send) { - yield* sleep(1); - yield* send(1); - yield* sleep(1); - yield* send(2); - yield* sleep(1); - yield* send(3); - }); + it("creates a batch when maxTime expires", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = batch({ maxTime: 5 })(faucet); + + const subscription = yield* stream; + + yield* faucet.pour(function* (send) { + yield* sleep(1); + yield* send(1); + yield* sleep(1); + yield* send(2); + yield* sleep(1); + yield* send(3); + }); - yield* sleep(10); + yield* sleep(10); - let next = yield* subscription.next(); + let next = yield* subscription.next(); - expect(next.value).toEqual([1, 2, 3]); - }); + expect(next.value).toEqual([1, 2, 3]); }); - it("creates a batch by maxSize when maxTime is not set", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = batch({ maxSize: 3 })(faucet); + it("creates a batch by maxSize when maxTime is not set", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = batch({ maxSize: 3 })(faucet); - const subscription = yield* stream; + const subscription = yield* stream; - yield* faucet.pour([1, 2, 3, 4, 5, 6]); + yield* faucet.pour([1, 2, 3, 4, 5, 6]); - let next = yield* subscription.next(); - expect(next.value).toEqual([1, 2, 3]); + let next = yield* subscription.next(); + expect(next.value).toEqual([1, 2, 3]); - next = yield* subscription.next(); - expect(next.value).toEqual([4, 5, 6]); - }); + next = yield* subscription.next(); + expect(next.value).toEqual([4, 5, 6]); }); - it("creates a batch within maxTime when maxSize is never reached", async () => { - expect.assertions(1); - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = batch({ maxSize: 8, maxTime: 10 })(faucet); - const finished = withResolvers(); - - const batches: Readonly[] = []; - - yield* spawn(function* () { - for (const batch of yield* each(stream)) { - batches.push(batch); - if (batches.flat().length >= 10) { - finished.resolve(); - } - yield* each.next(); - } - }); - - yield* faucet.pour(function* (send) { - for (let i = 1; i <= 10; i++) { - yield* send(i); - yield* sleep(3); - } - }); - - yield* finished.operation; - - expect(batches).toEqual([ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9], - [10], - ]); + it("creates a batch within maxTime when maxSize is never reached", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = batch({ maxSize: 8, maxTime: 50 })(faucet); + + const batches = yield* createArraySignal([]); + + yield* spawn(() => + forEach(function* (batch) { + batches.push(batch); + }, stream) + ); + + yield* faucet.pour(function* (send) { + for (let i = 1; i <= 10; i++) { + yield* send(i); + yield* sleep(20); + } }); + + yield* is(batches, (list) => list.flat().length >= 10); + + expect(batches.valueOf()).toHaveLength(4); + expect(batches.valueOf().flat()).toHaveLength(10); }); - it("creates a batch within maxSize in maxTime window", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = batch({ maxSize: 5, maxTime: 3 })(faucet); + it("creates a batch within maxSize in maxTime window", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = batch({ maxSize: 5, maxTime: 3 })(faucet); - const batches: Readonly[] = []; + const batches = yield* createArraySignal([]); - yield* spawn(function* () { - for (const batch of yield* each(stream)) { - batches.push(batch); - yield* each.next(); - } - }); + yield* spawn(() => + forEach(function* (batch) { + batches.push(batch); + }, stream) + ); - yield* faucet.pour([1, 2, 3, 4, 5, 6]); - yield* sleep(5); + yield* faucet.pour([1, 2, 3, 4, 5, 6]); - expect(batches).toEqual([ - [1, 2, 3, 4, 5], - [6], - ]); - }); + yield* is(batches, (list) => list.length === 2); + + expect(batches.valueOf()).toEqual([[1, 2, 3, 4, 5], [6]]); }); }); diff --git a/stream-helpers/deno.json b/stream-helpers/deno.json index 0b716620..c5d3f218 100644 --- a/stream-helpers/deno.json +++ b/stream-helpers/deno.json @@ -1,12 +1,13 @@ { "name": "@effectionx/stream-helpers", - "version": "0.2.0", + "version": "0.3.0", "imports": { "effection": "npm:effection@^3", "immutable": "npm:immutable@^5", - "@effectionx/signals": "npm:@effectionx/signals@^0.1.0", + "@effectionx/signals": "npm:@effectionx/signals@0.3.0", "@std/expect": "jsr:@std/expect@^1", "@std/testing": "jsr:@std/testing@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2", "remeda": "npm:remeda@2.21.3" }, "license": "MIT", diff --git a/stream-helpers/filter.test.ts b/stream-helpers/filter.test.ts index f629ff81..c2b235c8 100644 --- a/stream-helpers/filter.test.ts +++ b/stream-helpers/filter.test.ts @@ -1,51 +1,47 @@ +import { describe, it } from "@effectionx/bdd"; import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; -import { run, sleep } from "effection"; +import { sleep } from "effection"; import { filter } from "./filter.ts"; import { useFaucet } from "./test-helpers/faucet.ts"; describe("filter", () => { - it("filters items based on a predicate", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = filter(function* (x: number) { - yield* sleep(10); - return x > 5; - })(faucet); + it("filters items based on a predicate", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = filter(function* (x: number) { + yield* sleep(10); + return x > 5; + })(faucet); - const subscription = yield* stream; + const subscription = yield* stream; - yield* faucet.pour([1, 6, 2, 7, 3, 8]); + yield* faucet.pour([1, 6, 2, 7, 3, 8]); - let next = yield* subscription.next(); - expect(next.value).toEqual(6); + let next = yield* subscription.next(); + expect(next.value).toEqual(6); - next = yield* subscription.next(); - expect(next.value).toEqual(7); + next = yield* subscription.next(); + expect(next.value).toEqual(7); - next = yield* subscription.next(); - expect(next.value).toEqual(8); - }); + next = yield* subscription.next(); + expect(next.value).toEqual(8); }); - it("preserves stream order", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = filter(function* (x: number) { - yield* sleep(x * 10); // Longer sleep for larger numbers - return x % 2 === 0; // Keep even numbers - })(faucet); + it("preserves stream order", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = filter(function* (x: number) { + yield* sleep(x * 10); // Longer sleep for larger numbers + return x % 2 === 0; // Keep even numbers + })(faucet); - const subscription = yield* stream; + const subscription = yield* stream; - yield* faucet.pour([3, 2, 1, 4]); + yield* faucet.pour([3, 2, 1, 4]); - let next = yield* subscription.next(); - expect(next.value).toEqual(2); + let next = yield* subscription.next(); + expect(next.value).toEqual(2); - next = yield* subscription.next(); - expect(next.value).toEqual(4); - }); + next = yield* subscription.next(); + expect(next.value).toEqual(4); }); }); diff --git a/stream-helpers/for-each.test.ts b/stream-helpers/for-each.test.ts new file mode 100644 index 00000000..da3e8ad7 --- /dev/null +++ b/stream-helpers/for-each.test.ts @@ -0,0 +1,41 @@ +import { createSignal, spawn } from "effection"; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; + +import { forEach } from "./for-each.ts"; + +describe("forEach", () => { + it("should invoke function for each item in the stream", function* () { + const stream = createSignal(); + const processedItems: number[] = []; + + yield* spawn(() => + forEach(function* (item: number) { + processedItems.push(item); + }, stream) + ); + + stream.send(1); + stream.send(2); + stream.send(3); + + expect(processedItems).toEqual([1, 2, 3]); + }); + + it("should return the close value of the stream", function* () { + const stream = createSignal(); + + const result = yield* spawn(() => + forEach(function* () { + // Just process the item + }, stream) + ); + + stream.send("hello"); + stream.send("world"); + stream.close(42); // Close with value 42 + + const closeValue = yield* result; + expect(closeValue).toBe(42); + }); +}); diff --git a/stream-helpers/for-each.ts b/stream-helpers/for-each.ts new file mode 100644 index 00000000..f6aa7665 --- /dev/null +++ b/stream-helpers/for-each.ts @@ -0,0 +1,38 @@ +import type { Operation, Stream } from "effection"; + +/** + * Invoke a function for each item passing through the stream. + * + * @template T - The type of items in the stream + * @template TClose - The type of the close value returned when the stream ends + * @param fn - A function that processes each item from the stream. + * @param stream: A stream to process + * + * @example + * ```typescript + * import { forEach } from "./for-each.ts"; + * import { createSignal } from "effection"; + * + * // Process items from a stream + * const stream = createSignal(); + * + * yield* spawn(() => forEach(function*(item) { + * console.log(`Processing: ${item}`); + * }, stream)); + * + * yield* stream.send(1); + * yield* stream.send(2); + * ``` + */ +export function* forEach( + fn: (item: T) => Operation, + stream: Stream, +): Operation { + let subscription = yield* stream; + let next = yield* subscription.next(); + while (!next.done) { + yield* fn(next.value); + next = yield* subscription.next(); + } + return next.value; +} diff --git a/stream-helpers/map.test.ts b/stream-helpers/map.test.ts index 47b72ca5..b859526d 100644 --- a/stream-helpers/map.test.ts +++ b/stream-helpers/map.test.ts @@ -1,54 +1,50 @@ +import { describe, it } from "@effectionx/bdd"; import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; -import { run, sleep } from "effection"; +import { sleep } from "effection"; import { map } from "./map.ts"; import { useFaucet } from "./test-helpers/faucet.ts"; describe("map", () => { - it("handles operation transformations", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = map(function* (x: number) { - yield* sleep(10); - return x * 2; - })(faucet); + it("handles operation transformations", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = map(function* (x: number) { + yield* sleep(10); + return x * 2; + })(faucet); - const subscription = yield* stream; + const subscription = yield* stream; - yield* faucet.pour([1, 2, 3]); + yield* faucet.pour([1, 2, 3]); - let next = yield* subscription.next(); - expect(next.value).toEqual(2); + let next = yield* subscription.next(); + expect(next.value).toEqual(2); - next = yield* subscription.next(); - expect(next.value).toEqual(4); + next = yield* subscription.next(); + expect(next.value).toEqual(4); - next = yield* subscription.next(); - expect(next.value).toEqual(6); - }); + next = yield* subscription.next(); + expect(next.value).toEqual(6); }); - it("preserves stream order", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const stream = map(function* (x: number) { - yield* sleep(x * 10); // Longer sleep for larger numbers - return x; - })(faucet); + it("preserves stream order", function* () { + const faucet = yield* useFaucet({ open: true }); + const stream = map(function* (x: number) { + yield* sleep(x * 10); // Longer sleep for larger numbers + return x; + })(faucet); - const subscription = yield* stream; + const subscription = yield* stream; - yield* faucet.pour([3, 1, 2]); + yield* faucet.pour([3, 1, 2]); - let next = yield* subscription.next(); - expect(next.value).toEqual(3); + let next = yield* subscription.next(); + expect(next.value).toEqual(3); - next = yield* subscription.next(); - expect(next.value).toEqual(1); + next = yield* subscription.next(); + expect(next.value).toEqual(1); - next = yield* subscription.next(); - expect(next.value).toEqual(2); - }); + next = yield* subscription.next(); + expect(next.value).toEqual(2); }); }); diff --git a/stream-helpers/mod.test.ts b/stream-helpers/mod.test.ts deleted file mode 100644 index 28597545..00000000 --- a/stream-helpers/mod.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; -import { assertSpyCalls, spy } from "@std/testing/mock"; -import { each, run, sleep, spawn, withResolvers } from "effection"; -import { pipe } from "remeda"; - -import { batch } from "./batch.ts"; -import { map } from "./map.ts"; -import { useFaucet } from "./test-helpers/faucet.ts"; -import { createTracker } from "./tracker.ts"; -import { valve } from "./valve.ts"; - -describe("batch, valve and map composition", () => { - it("should process data through both batch and valve", async () => { - await run(function* () { - // Create a faucet as our data source - const faucet = yield* useFaucet({ open: true }); - - const tracker = yield* createTracker(); - - // Create spies for valve operations - const close = spy(function* () { - faucet.close(); - }); - - const open = spy(function* () { - faucet.open(); - }); - - // Compose the streams using pipe - const composedStream = pipe( - faucet, - tracker.passthrough(), - valve({ - closeAt: 5, - open, - close, - openAt: 0, - }), - map(function* (x) { - yield* sleep(10); - return { id: x, value: x * 2 }; - }), - batch({ maxSize: 3, maxTime: 20 }), - ); - - let { resolve, operation } = withResolvers(); - let results: Readonly<{ id: number; value: number }[]>[] = []; - - // Process the stream - yield* spawn(function* () { - let count = 0; - for (const items of yield* each(composedStream)) { - tracker.markMany(items.map((x) => x.id)); - results.push(items); - count = count + items.length; - yield* sleep(1); - if (count >= 10) { - resolve(); - } - yield* each.next(); - } - }); - - // Pour data into the faucet - yield* faucet.pour(function* (send) { - for (let number of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { - yield* send(number); - yield* sleep(1); - } - }); - - yield* tracker; - yield* operation; - - // Verify the results - const flatResults = results.flat(); - expect(flatResults).toEqual([ - { id: 1, value: 2 }, - { id: 2, value: 4 }, - { id: 3, value: 6 }, - { id: 4, value: 8 }, - { id: 5, value: 10 }, - { id: 6, value: 12 }, - { id: 7, value: 14 }, - { id: 8, value: 16 }, - { id: 9, value: 18 }, - { id: 10, value: 20 }, - ]); - - // Verify the valve operations were called - assertSpyCalls(close, 1); - assertSpyCalls(open, 1); - - // Verify the batching worked correctly - const batchSizes = results.map((batch) => batch.length); - expect(batchSizes.every((size) => size <= 3)).toBe(true); - }); - }); -}); diff --git a/stream-helpers/mod.ts b/stream-helpers/mod.ts index 2e96e44d..376b63ea 100644 --- a/stream-helpers/mod.ts +++ b/stream-helpers/mod.ts @@ -3,3 +3,4 @@ export * from "./valve.ts"; export * from "./map.ts"; export * from "./filter.ts"; export * from "./tracker.ts"; +export * from "./for-each.ts"; diff --git a/stream-helpers/test-helpers/faucet.test.ts b/stream-helpers/test-helpers/faucet.test.ts index a98ec828..879ca54d 100644 --- a/stream-helpers/test-helpers/faucet.test.ts +++ b/stream-helpers/test-helpers/faucet.test.ts @@ -1,121 +1,113 @@ -import { useFaucet } from "./faucet.ts"; +import { describe, it } from "@effectionx/bdd"; import { createArraySignal, is } from "@effectionx/signals"; - -import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { each, race, run, sleep, spawn } from "effection"; +import { each, race, sleep, spawn } from "effection"; + +import { useFaucet } from "./faucet.ts"; describe("useFaucet", () => { - it("creates a faucet that can pour items", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const results = yield* createArraySignal([]); - - // Spawn a subscription to the faucet - yield* spawn(function* () { - for (const item of yield* each(faucet)) { - results.push(item); - yield* each.next(); - } - }); + it("creates a faucet that can pour items", function* () { + const faucet = yield* useFaucet({ open: true }); + const results = yield* createArraySignal([]); + + // Spawn a subscription to the faucet + yield* spawn(function* () { + for (const item of yield* each(faucet)) { + results.push(item); + yield* each.next(); + } + }); - // Pour an array of items - yield* faucet.pour([1, 2, 3]); + // Pour an array of items + yield* faucet.pour([1, 2, 3]); - // Wait for processing - yield* is(results, (results) => results.length === 3); + // Wait for processing + yield* is(results, (results) => results.length === 3); - expect(results.valueOf()).toEqual([1, 2, 3]); - }); + expect(results.valueOf()).toEqual([1, 2, 3]); }); - it("respects the open state", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: false }); - const results = yield* createArraySignal([]); - - // Spawn a subscription to the faucet - yield* spawn(function* () { - for (const item of yield* each(faucet)) { - results.push(item); - yield* each.next(); - } - }); + it("respects the open state", function* () { + const faucet = yield* useFaucet({ open: false }); + const results = yield* createArraySignal([]); - // Try to pour while closed, give it a timeout to avoid it getting stuck - yield* race([faucet.pour([1, 2, 3]), sleep(1)]); + // Spawn a subscription to the faucet + yield* spawn(function* () { + for (const item of yield* each(faucet)) { + results.push(item); + yield* each.next(); + } + }); - expect(results.valueOf()).toEqual([]); + // Try to pour while closed, give it a timeout to avoid it getting stuck + yield* race([faucet.pour([1, 2, 3]), sleep(1)]); - faucet.open(); + expect(results.valueOf()).toEqual([]); - yield* faucet.pour([4, 5, 6]); + faucet.open(); - expect(results.valueOf()).toEqual([4, 5, 6]); - }); + yield* faucet.pour([4, 5, 6]); + + expect(results.valueOf()).toEqual([4, 5, 6]); }); - it("supports pouring with an operation", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const results = yield* createArraySignal([]); - - // Spawn a subscription to the faucet - yield* spawn(function* () { - for (const item of yield* each(faucet)) { - results.push(item); - yield* each.next(); - } - }); + it("supports pouring with an operation", function* () { + const faucet = yield* useFaucet({ open: true }); + const results = yield* createArraySignal([]); + + // Spawn a subscription to the faucet + yield* spawn(function* () { + for (const item of yield* each(faucet)) { + results.push(item); + yield* each.next(); + } + }); - // Pour using a generator function - yield* spawn(function* () { - yield* faucet.pour(function* (send) { - yield* send(1); - yield* sleep(10); - yield* send(2); - yield* sleep(10); - yield* send(3); - }); + // Pour using a generator function + yield* spawn(function* () { + yield* faucet.pour(function* (send) { + yield* send(1); + yield* sleep(10); + yield* send(2); + yield* sleep(10); + yield* send(3); }); + }); - yield* is(results, (results) => results.length === 3); + yield* is(results, (results) => results.length === 3); - expect(results.valueOf()).toEqual([1, 2, 3]); - }); + expect(results.valueOf()).toEqual([1, 2, 3]); }); - it("stops pouring when closed", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); - const results = yield* createArraySignal([]); - - // Spawn a subscription to the faucet - yield* spawn(function* () { - for (const item of yield* each(faucet)) { - results.push(item); - yield* each.next(); - } - }); + it("stops pouring when closed", function* () { + const faucet = yield* useFaucet({ open: true }); + const results = yield* createArraySignal([]); + + // Spawn a subscription to the faucet + yield* spawn(function* () { + for (const item of yield* each(faucet)) { + results.push(item); + yield* each.next(); + } + }); - // Start pouring with a generator - yield* spawn(function* () { - yield* faucet.pour(function* (send) { - yield* send(1); - yield* sleep(10); - yield* send(2); - yield* sleep(10); - // Close the faucet - faucet.close(); - // This should not be sent because we'll close the faucet - yield* send(3); - }); + // Start pouring with a generator + yield* spawn(function* () { + yield* faucet.pour(function* (send) { + yield* send(1); + yield* sleep(10); + yield* send(2); + yield* sleep(10); + // Close the faucet + faucet.close(); + // This should not be sent because we'll close the faucet + yield* send(3); }); + }); - // Wait for the first item - yield* sleep(50); + // Wait for the first item + yield* sleep(50); - expect(results.valueOf()).toEqual([1, 2]); - }); + expect(results.valueOf()).toEqual([1, 2]); }); }); diff --git a/stream-helpers/tracker.test.ts b/stream-helpers/tracker.test.ts index a8d73234..105f5755 100644 --- a/stream-helpers/tracker.test.ts +++ b/stream-helpers/tracker.test.ts @@ -1,6 +1,6 @@ -import { each, run, sleep, spawn, withResolvers } from "effection"; +import { describe, it } from "@effectionx/bdd"; import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; +import { each, sleep, spawn, withResolvers } from "effection"; import { pipe } from "remeda"; import { batch } from "./batch.ts"; @@ -9,110 +9,104 @@ import { useFaucet } from "./test-helpers/faucet.ts"; import { createTracker } from "./tracker.ts"; describe("tracker", () => { - it("waits for all items to be processed", async () => { - expect.assertions(1); - await run(function* () { - const { operation, resolve } = withResolvers(); - const tracker = yield* createTracker(); - const faucet = yield* useFaucet({ open: true }); + it("waits for all items to be processed", function* () { + const { operation, resolve } = withResolvers(); + const tracker = yield* createTracker(); + const faucet = yield* useFaucet({ open: true }); - const stream = pipe( - faucet, - tracker.passthrough(), - map(function* (x: number) { - yield* sleep(10); - return x; - }), - ); + const stream = pipe( + faucet, + tracker.passthrough(), + map(function* (x: number) { + yield* sleep(10); + return x; + }), + ); - const received: number[] = []; + const received: number[] = []; - yield* spawn(function* () { - let count = 0; - for (const item of yield* each(stream)) { - yield* sleep(10); - received.push(item); - tracker.markOne(item); - count++; - if (count >= 3) { - resolve(); - } - yield* each.next(); + yield* spawn(function* () { + let count = 0; + for (const item of yield* each(stream)) { + yield* sleep(10); + received.push(item); + tracker.markOne(item); + count++; + if (count >= 3) { + resolve(); } - }); + yield* each.next(); + } + }); - yield* faucet.pour(function* (send) { - yield* send(1); - yield* sleep(1); - yield* send(2); - yield* sleep(1); - yield* send(3); - }); + yield* faucet.pour(function* (send) { + yield* send(1); + yield* sleep(1); + yield* send(2); + yield* sleep(1); + yield* send(3); + }); - yield* operation; - yield* tracker; + yield* operation; + yield* tracker; - expect(received).toEqual([1, 2, 3]); - }); + expect(received).toEqual([1, 2, 3]); }); - it("tracks batched items", async () => { - expect.assertions(1); - await run(function* () { - const { operation, resolve } = withResolvers(); + it("tracks batched items", function* () { + const { operation, resolve } = withResolvers(); - const tracker = yield* createTracker(); - const faucet = yield* useFaucet({ open: true }); - const stream = pipe( - faucet, - tracker.passthrough(), - batch({ - maxSize: 3, - }), - map(function* (items) { - yield* sleep(10); - return items; - }), - ); + const tracker = yield* createTracker(); + const faucet = yield* useFaucet({ open: true }); + const stream = pipe( + faucet, + tracker.passthrough(), + batch({ + maxSize: 3, + }), + map(function* (items) { + yield* sleep(10); + return items; + }), + ); - const received: Readonly[] = []; + const received: Readonly[] = []; - yield* spawn(function* () { - let count = 0; - for (const items of yield* each(stream)) { - received.push(items); - tracker.markMany(items); - count = count + items.length; - if (count >= 9) { - resolve(); - } - yield* each.next(); + yield* spawn(function* () { + let count = 0; + for (const items of yield* each(stream)) { + received.push(items); + tracker.markMany(items); + count = count + items.length; + if (count >= 9) { + resolve(); } - }); + yield* each.next(); + } + }); - yield* faucet.pour(function* (send) { - yield* send(1); - yield* sleep(10); - yield* send(2); - yield* sleep(10); - yield* send(3); - yield* sleep(10); - yield* send(4); - yield* sleep(10); - yield* send(5); - yield* sleep(10); - yield* send(6); - yield* sleep(10); - yield* send(7); - yield* sleep(10); - yield* send(8); - yield* sleep(10); - yield* send(9); - }); + yield* faucet.pour(function* (send) { + yield* send(1); + yield* sleep(10); + yield* send(2); + yield* sleep(10); + yield* send(3); + yield* sleep(10); + yield* send(4); + yield* sleep(10); + yield* send(5); + yield* sleep(10); + yield* send(6); + yield* sleep(10); + yield* send(7); + yield* sleep(10); + yield* send(8); + yield* sleep(10); + yield* send(9); + }); - yield* operation; - yield* tracker; + yield* operation; + yield* tracker; - expect(received).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9]]); - }); + expect(received).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9]]); }); }); diff --git a/stream-helpers/valve.test.ts b/stream-helpers/valve.test.ts index 03d4e93c..c6bb1e73 100644 --- a/stream-helpers/valve.test.ts +++ b/stream-helpers/valve.test.ts @@ -1,50 +1,48 @@ +import { describe, it } from "@effectionx/bdd"; import { createArraySignal, is } from "@effectionx/signals"; import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; import { assertSpyCalls, spy } from "@std/testing/mock"; -import { each, run, sleep, spawn } from "effection"; +import { each, sleep, spawn } from "effection"; -import { useFaucet } from "./test-helpers/faucet.ts"; import { valve } from "./valve.ts"; +import { useFaucet } from "./test-helpers/faucet.ts"; describe("valve", () => { - it("closes and opens the valve", async () => { - await run(function* () { - const faucet = yield* useFaucet({ open: true }); + it("closes and opens the valve", function* () { + const faucet = yield* useFaucet({ open: true }); - const close = spy(function* () { - faucet.close(); - }); + const close = spy(function* () { + faucet.close(); + }); - const open = spy(function* () { - faucet.open(); - }); + const open = spy(function* () { + faucet.open(); + }); - const stream = valve({ - closeAt: 5, - close, - open, - openAt: 2, - }); + const stream = valve({ + closeAt: 5, + close, + open, + openAt: 2, + }); - const values = yield* createArraySignal([]); + const values = yield* createArraySignal([]); - yield* spawn(function* () { - for (const value of yield* each(stream(faucet))) { - values.push(value); - yield* sleep(1); - yield* each.next(); - } - }); + yield* spawn(function* () { + for (const value of yield* each(stream(faucet))) { + values.push(value); + yield* sleep(1); + yield* each.next(); + } + }); - yield* faucet.pour([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + yield* faucet.pour([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - yield* is(values, (values) => values.length === 10); + yield* is(values, (values) => values.length === 10); - expect(values.valueOf()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(values.valueOf()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - assertSpyCalls(close, 1); - assertSpyCalls(open, 1); - }); + assertSpyCalls(close, 1); + assertSpyCalls(open, 1); }); }); diff --git a/task-buffer/deno.json b/task-buffer/deno.json index be7a9950..bd16fb2f 100644 --- a/task-buffer/deno.json +++ b/task-buffer/deno.json @@ -1,9 +1,12 @@ { "name": "@effectionx/task-buffer", - "version": "1.1.0", + "version": "1.0.1", "exports": "./mod.ts", "license": "MIT", "imports": { - "effection": "npm:effection@4.0.0-beta.2" + "effection": "npm:effection@^3", + "@std/expect": "jsr:@std/expect@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2", + "@std/testing": "jsr:@std/testing@^1" } } diff --git a/task-buffer/task-buffer.test.ts b/task-buffer/task-buffer.test.ts index 89c00dff..df8e480a 100644 --- a/task-buffer/task-buffer.test.ts +++ b/task-buffer/task-buffer.test.ts @@ -1,12 +1,15 @@ -import { run, sleep, spawn, type Task } from "effection"; -import { describe, it } from "bdd"; -import { expect } from "expect"; +import { FakeTime } from "@std/testing/time"; +import { sleep, spawn, type Task } from "effection"; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; import { useTaskBuffer } from "./task-buffer.ts"; describe("TaskBuffer", () => { - it("queues up tasks when the buffer fills up", async () => { - await run(function* () { - let buffer = yield* useTaskBuffer(2); + it("queues up tasks when the buffer fills up", function* () { + const time = new FakeTime(); + + try { + const buffer = yield* useTaskBuffer(2); yield* buffer.spawn(() => sleep(10)); yield* buffer.spawn(() => sleep(10)); @@ -16,21 +19,26 @@ describe("TaskBuffer", () => { third = yield* yield* buffer.spawn(() => sleep(10)); }); - yield* sleep(5); + time.tick(5); + // right now the third spawn is queued up, but not spawned. expect(third).toBeUndefined(); - yield* sleep(10); + time.tick(10); // the other tasks finished and so the third task is active. expect(third).toBeDefined(); - }); + } finally { + time.restore(); + } }); - it("allows to wait until buffer is drained", async () => { - let finished = 0; - await run(function* () { - let buffer = yield* useTaskBuffer(5); + it("allows to wait until buffer is drained", function* () { + const time = new FakeTime(); + + try { + let finished = 0; + const buffer = yield* useTaskBuffer(5); for (let i = 0; i < 3; i++) { yield* buffer.spawn(function* () { yield* sleep(10); @@ -40,9 +48,12 @@ describe("TaskBuffer", () => { expect(finished).toEqual(0); + time.tick(10); yield* buffer; expect(finished).toEqual(3); - }); + } finally { + time.restore(); + } }); }); diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index f330537c..ba1f87ea 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -1,6 +1,5 @@ import { build, emptyDir } from "@deno/dnt"; import { join } from "@std/path"; - import { DenoJson } from "./lib/read-packages.ts"; let [workspace] = Deno.args; diff --git a/test-adapter/deno.json b/test-adapter/deno.json index b118da32..bda1334a 100644 --- a/test-adapter/deno.json +++ b/test-adapter/deno.json @@ -1,6 +1,6 @@ { "name": "@effectionx/test-adapter", - "version": "0.5.1", + "version": "0.1.0", "license": "MIT", "exports": "./mod.ts", "imports": { diff --git a/test-adapter/test/adapter.test.ts b/test-adapter/test/adapter.test.ts index 941afd63..af7a0f4f 100644 --- a/test-adapter/test/adapter.test.ts +++ b/test-adapter/test/adapter.test.ts @@ -1,5 +1,5 @@ -import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; import { createContext, resource } from "effection"; import { createTestAdapter } from "../mod.ts"; diff --git a/timebox/deno.json b/timebox/deno.json index 9a853020..a426a61f 100644 --- a/timebox/deno.json +++ b/timebox/deno.json @@ -1,9 +1,11 @@ { "name": "@effectionx/timebox", - "version": "0.2.0", + "version": "0.1.0", "license": "MIT", "exports": "./mod.ts", "imports": { - "effection": "npm:effection@^4.0.0-beta.2" + "effection": "npm:effection@^3", + "@std/expect": "jsr:@std/expect@1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2" } } diff --git a/timebox/timebox.test.ts b/timebox/timebox.test.ts index 8c160514..366c9354 100644 --- a/timebox/timebox.test.ts +++ b/timebox/timebox.test.ts @@ -1,11 +1,11 @@ -import { describe, it } from "bdd"; -import { expect } from "expect"; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; import { timebox } from "./mod.ts"; -import { type Operation, run, sleep, suspend } from "effection"; +import { type Operation, sleep, suspend } from "effection"; describe("timebox", () => { - it("is completed if operation returns within alloted time", async () => { - let outcome = await run(() => timebox(100, delayed(5, () => "hello"))); + it("is completed if operation returns within alloted time", function* () { + let outcome = yield* timebox(100, delayed(5, () => "hello")); outcome.timeout; expect(outcome).toMatchObject({ timeout: false, @@ -13,22 +13,26 @@ describe("timebox", () => { }); }); - it("is completed if operation throws within alloted time", async () => { - let task = run(() => - timebox( + it("is completed if operation throws within alloted time", function* () { + try { + yield* timebox( 100, delayed(5, () => { throw new Error("boom!"); }), - ) - ); - await expect(task).rejects.toMatchObject({ - message: "boom!", - }); + ); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + expect(error).toMatchObject({ + message: "boom!", + }); + } }); - it("is timed out if operation does not return within alloted time", async () => { - await expect(run(() => timebox(10, suspend))).resolves.toMatchObject({ + it("is timed out if operation does not return within alloted time", function* () { + let outcome = yield* timebox(10, suspend); + expect(outcome).toMatchObject({ timeout: true, }); }); diff --git a/watch/child-process.ts b/watch/child-process.ts deleted file mode 100644 index d8435005..00000000 --- a/watch/child-process.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { spawn as nodeSpawn } from "node:child_process"; - -type Signals = - | "SIGABRT" - | "SIGALRM" - | "SIGBUS" - | "SIGCHLD" - | "SIGCONT" - | "SIGFPE" - | "SIGHUP" - | "SIGILL" - | "SIGINT" - | "SIGIO" - | "SIGIOT" - | "SIGKILL" - | "SIGPIPE" - | "SIGPOLL" - | "SIGPROF" - | "SIGPWR" - | "SIGQUIT" - | "SIGSEGV" - | "SIGSTKFLT" - | "SIGSTOP" - | "SIGSYS" - | "SIGTERM" - | "SIGTRAP" - | "SIGTSTP" - | "SIGTTIN" - | "SIGTTOU" - | "SIGUNUSED" - | "SIGURG" - | "SIGUSR1" - | "SIGUSR2" - | "SIGVTALRM" - | "SIGWINCH" - | "SIGXCPU" - | "SIGXFSZ"; - -import type { Operation, Stream } from "effection"; -import { - action, - createSignal, - resource, - spawn, - withResolvers, -} from "effection"; - -export interface ProcessResult { - code: number; - signal?: Signals; -} - -export interface Process extends Operation { - stdout: Stream; - stderr: Stream; - send(signal: Signals): void; -} - -export function useProcess(command: string): Operation { - return resource(function* (provide) { - let closed = withResolvers(); - let stdout = createSignal(); - let stderr = createSignal(); - let nodeproc = nodeSpawn(command, { - shell: true, - stdio: "pipe", - }); - - // fail on an "error" event, but only until the process is successfully spawned. - yield* spawn(function* () { - yield* spawn(() => - action((_, reject) => { - nodeproc.on("error", reject); - return () => nodeproc.off("error", reject); - }) - ); - - yield* action((resolve) => { - nodeproc.on("spawn", resolve); - return () => nodeproc.off("spawn", resolve); - }); - }); - - let onstdout = (chunk: unknown) => { - stdout.send(String(chunk)); - }; - let onstderr = (chunk: unknown) => { - stderr.send(String(chunk)); - }; - let onclose = (code: number, signal?: Signals) => { - stdout.close(); - stderr.close(); - closed.resolve({ code, signal }); - }; - - try { - nodeproc.stdout.on("data", onstdout); - nodeproc.stderr.on("data", onstderr); - nodeproc.on("close", onclose); - - yield* provide({ - [Symbol.iterator]: closed.operation[Symbol.iterator], - stdout, - stderr, - *send(signal) { - nodeproc.kill(signal); - }, - }); - } finally { - nodeproc.kill("SIGINT"); - nodeproc.kill("SIGTERM"); - yield* closed.operation; - stdout.close(); - stderr.close(); - nodeproc.stdout.off("data", onstdout); - nodeproc.stderr.off("data", onstderr); - nodeproc.off("close", onclose); - } - }); -} diff --git a/watch/deno.json b/watch/deno.json index 22f54406..95142546 100644 --- a/watch/deno.json +++ b/watch/deno.json @@ -1,21 +1,26 @@ { "name": "@effectionx/watch", - "version": "0.2.0", + "version": "0.1.1", "license": "MIT", "exports": { ".": "./main.ts", "./lib": "./mod.ts" }, "imports": { - "effection": "npm:effection@^4.0.0-beta.2", + "effection": "npm:effection@^3", "ignore": "npm:ignore@^7.0.3", + "@std/expect": "jsr:@std/expect@^1", + "@std/assert": "jsr:@std/assert@^1", "@gordonb/pipe": "jsr:@gordonb/pipe@0.1.0", "@std/fs": "jsr:@std/fs@^1", "@std/path": "jsr:@std/path@^1", - "@std/assert": "jsr:@std/assert@^1", "chokidar": "npm:chokidar@^4.0.3", "zod": "npm:zod@^3.20.2", - "zod-opts": "npm:zod-opts@0.1.8" + "zod-opts": "npm:zod-opts@0.1.8", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2", + "@effectionx/process": "jsr:@effectionx/process@0.5.0", + "@effectionx/stream-helpers": "jsr:@effectionx/stream-helpers@0.3.0", + "@effectionx/signals": "npm:@effectionx/signals@0.3.0" }, "lint": { "rules": { diff --git a/watch/test/cat.ts b/watch/test/cat.ts new file mode 100644 index 00000000..246c4cc2 --- /dev/null +++ b/watch/test/cat.ts @@ -0,0 +1,15 @@ +if (Deno.args.length !== 1) { + console.error("Usage: deno run cat.ts "); + Deno.exit(1); +} + +const filename = Deno.args[0]; + +try { + const content = await Deno.readTextFile(filename); + const encoder = new TextEncoder(); + await Deno.stdout.write(encoder.encode(content)); +} catch (error) { + console.error(`Error reading file "${filename}":`, error.message); + Deno.exit(1); +} diff --git a/watch/test/helpers.ts b/watch/test/helpers.ts new file mode 100644 index 00000000..067a16ce --- /dev/null +++ b/watch/test/helpers.ts @@ -0,0 +1,138 @@ +import type { Process } from "@effectionx/process"; +import { assert } from "@std/assert"; +import { emptyDir, ensureDir } from "@std/fs"; +import { dirname, fromFileUrl, join } from "@std/path"; +import type { Operation, Result, Stream } from "effection"; +import { each, Ok, sleep, spawn, until } from "effection"; +import { cp, readFile, writeFile } from "node:fs/promises"; + +import type { Start } from "../watch.ts"; + +export interface Fixture { + path: string; + getPath(filename: "src/file.txt" | "dist/artifact.txt"): string; + read(name: "src/file.txt"): Operation; + write( + filename: "src/file.txt" | "dist/artifact.txt", + content: string, + ): Operation; +} + +export function* useFixture(): Operation { + let tmpDir = fromFileUrl(new URL("./temp", import.meta.url)); + let fixtureDir = fromFileUrl(new URL("./fixtures", import.meta.url)); + // let path = join(tmpDir, "fixtures"); + let path = tmpDir; + try { + yield* until(emptyDir(tmpDir)); + } catch (e) { + console.log(`Encountered error clearing ${tmpDir}`); + console.error(e); + } + + try { + yield* until( + cp(fixtureDir, tmpDir, { + recursive: true, + preserveTimestamps: true, + force: true, + }), + ); + } catch (e) { + console.log(`Encountered error copying from ${fixtureDir} to ${tmpDir}`); + console.error(e); + } + + return { + path, + getPath(filename): string { + return join(path, filename); + }, + *write(filename: string, content: string) { + const dest = join(path, filename); + yield* until(ensureDir(dirname(dest))); + yield* until(writeFile(join(path, filename), content)); + }, + *read(name) { + return String(yield* until(readFile(join(path, name)))); + }, + }; +} + +type SuccessfulStart = { + stdout: string; + stderr: string; + process: Process; +}; + +type ProcessStart = Result; + +export function* inspector(stream: Stream) { + let starts: ProcessStart[] = []; + + let expected = 0; + + yield* spawn(function* () { + for (let { result } of yield* each(stream)) { + if (result.ok) { + let process = result.value; + let start = { + stdout: "", + stderr: "", + process: result.value, + }; + starts.push(Ok(start)); + yield* spawn(function* () { + for (let chunk of yield* each(process.stdout)) { + start.stdout += String(chunk); + yield* each.next(); + } + }); + yield* spawn(function* () { + for (let chunk of yield* each(process.stderr)) { + start.stderr += String(chunk); + yield* each.next(); + } + }); + } else { + starts.push(result); + } + + yield* each.next(); + } + }); + + let inspector = { + starts, + *expectNext(): Operation { + let initial = expected; + for (let i = 0; i < 500; i++) { + if (initial < starts.length) { + yield* sleep(10); + expected = starts.length; + let result = inspector.starts[inspector.starts.length - 1]; + if (result.ok) { + return result.value; + } else { + throw new Error( + `expected successful start, but failed: ${result.error}`, + ); + } + } else { + yield* sleep(10); + } + } + throw new Error(`expecting a sucessful start but it never appeared.`); + }, + *expectNoRestart() { + let prexisting = inspector.starts.length; + yield* sleep(200); + let restarts = inspector.starts.length - prexisting; + assert( + restarts === 0, + `expected no process restarts to have happened, but instead there were: ${restarts}`, + ); + }, + }; + return inspector; +} diff --git a/watch/test/watch-graceful.ts b/watch/test/watch-graceful.ts index 9743275c..a112805d 100644 --- a/watch/test/watch-graceful.ts +++ b/watch/test/watch-graceful.ts @@ -1,8 +1,10 @@ -import { main, suspend } from "effection"; +import { main, sleep } from "effection"; + +console.log("started"); await main(function* () { try { - yield* suspend(); + yield* sleep(100_000_000); } finally { console.log("done"); } diff --git a/watch/test/watch.test.ts b/watch/test/watch.test.ts index 928caee0..f029d1ca 100644 --- a/watch/test/watch.test.ts +++ b/watch/test/watch.test.ts @@ -1,237 +1,118 @@ -import type { Operation, Result, Stream } from "effection"; -import { call, each, Ok, run, sleep, spawn } from "effection"; -import { describe, it as bddIt } from "bdd"; -import { expect } from "expect"; -import { assert } from "@std/assert"; -import { emptyDir } from "@std/fs"; - -// temporariy disable watch tests on linux because of -// https://github.com/denoland/deno/issues/28041 -function it(...args: Parameters) { - if (Deno.build.os === "linux") { - return bddIt.skip(...args); - } - return bddIt(...args); -} -it.skip = bddIt.skip; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; +import { createArraySignal, is } from "@effectionx/signals"; +import { forEach } from "@effectionx/stream-helpers"; -describe("watch", () => { - it("restarts the specified process when files change.", async () => { - await run(function* () { - let fixture = yield* useFixture(); - let processes = yield* inspector( - watch({ - path: fixture.path, - cmd: `cat ${fixture.getPath("src/file.txt")}`, - event: "change", - }), - ); - - let start = yield* processes.expectNext(); - - let exit = yield* start.process; +import process from "node:process"; +import { watch } from "../watch.ts"; +import { inspector, useFixture } from "./helpers.ts"; +import { spawn } from "effection"; - expect(exit.code).toEqual(0); +describe("watch", () => { + it("restarts the specified process when files change.", function* () { + let fixture = yield* useFixture(); + let processes = yield* inspector( + watch({ + path: fixture.path, + cmd: `deno run -A cat.ts`, + event: "change", + execOptions: { + cwd: import.meta.dirname, + env: { + PATH: process.env.PATH!, + }, + arguments: [fixture.getPath("src/file.txt")], + }, + }), + ); + + let start = yield* processes.expectNext(); + + yield* start.process.expect(); + + expect(start).toMatchObject({ + stderr: "", + stdout: "this is a source file", + }); - expect(start.stdout).toEqual("this is a source file"); + yield* fixture.write("src/file.txt", "this source file is changed"); - yield* call(() => - fixture.write("src/file.txt", "this source file is changed") - ); + let next = yield* processes.expectNext(); - let next = yield* processes.expectNext(); + yield* next.process.expect(); - expect(next.stdout).toEqual("this source file is changed"); + expect(next).toMatchObject({ + stderr: "", + stdout: "this source file is changed", }); }); - it("ignores files in .gitignore", async () => { - await run(function* () { - let fixture = yield* useFixture(); + it("ignores files in .gitignore", function* () { + let fixture = yield* useFixture(); - let processes = yield* inspector( - watch({ - path: fixture.path, - cmd: `echo hello`, - event: "change", - }), - ); + let processes = yield* inspector( + watch({ + path: fixture.path, + cmd: `echo hello`, + event: "change", + }), + ); - //it starts the first time - yield* processes.expectNext(); + //it starts the first time + yield* processes.expectNext(); - yield* fixture.write("dist/artifact.txt", "this file was built again"); + yield* fixture.write("dist/artifact.txt", "this file was built again"); - yield* processes.expectNoRestart(); - }); + yield* processes.expectNoRestart(); }); - it.skip("ignores files in a .gitignore that is in a parent directory", () => { + it.skip("ignores files in a .gitignore that is in a parent directory", function* () { // start an example in a nested directory than the git ignore // touch a change in an ignored file within the directory // enuser that there was no restart; }); - it("waits until stdout is closed before restarting", async () => { - await run(function* () { - let fixture = yield* useFixture(); - let processes = yield* inspector( - watch({ - path: fixture.path, - cmd: `deno run -A watch/test/watch-graceful.ts`, - }), - ); + it("waits until stdout is closed before restarting", function* () { + let fixture = yield* useFixture(); + let processes = yield* inspector( + watch({ + path: fixture.path, + cmd: `deno run -A watch-graceful.ts`, + execOptions: { + cwd: import.meta.dirname, + env: { + PATH: process.env.PATH!, + }, + }, + }), + ); + + const output = yield* createArraySignal([]); + + let first = yield* processes.expectNext(); + + yield* spawn(function* () { + yield* forEach(function* (line) { + output.push(`${line}`.trim()); + }, first.process.stdout); + }); - let first = yield* processes.expectNext(); + yield* is(output, (array) => array.includes("started")); - yield* fixture.write("src/file.txt", "hello planet"); + yield* fixture.write("src/file.txt", "hello planet"); - yield* processes.expectNext(); + yield* is(output, (array) => array.includes("done")); - expect(first.stdout).toEqual("done\n"); - }); + yield* processes.expectNext(); - // start an example that prints "done" to the console upon SIGINT + expect(output.valueOf()).toEqual(["started", "done"]); }); - it.skip("allows for a hard kill ", () => { + // start an example that prints "done" to the console upon SIGINT); + + it.skip("allows for a hard kill ", function* () { // start an example that will suspend asked to exit and so will // never exit. // send the command to exit the watch and the main returns }); }); - -import { type Start, watch } from "../watch.ts"; -import type { Process } from "../child-process.ts"; -import { cp, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "@std/path"; -import { ensureDir } from "@std/fs/ensure-dir"; - -interface Fixture { - path: string; - getPath(filename: "src/file.txt" | "dist/artifact.txt"): string; - read(name: "src/file.txt"): Operation; - write( - filename: "src/file.txt" | "dist/artifact.txt", - content: string, - ): Operation; -} - -function* useFixture(): Operation { - let tmpDir = new URL("./temp", import.meta.url).pathname; - let fixtureDir = new URL("./fixtures", import.meta.url).pathname; - let path = join(tmpDir, "fixtures"); - yield* call(() => emptyDir(tmpDir)); - - yield* call(() => - cp(fixtureDir, tmpDir, { - recursive: true, - preserveTimestamps: true, - force: true, - }) - ); - - return { - path, - getPath(filename): string { - return join(path, filename); - }, - write(filename: string, content: string) { - return call(async () => { - const dest = join(path, filename); - await ensureDir(dirname(dest)); - await writeFile(join(path, filename), content); - }); - }, - *read(name) { - return String(yield* call(() => readFile(join(path, name)))); - }, - }; -} - -type SuccessfulStart = { - stdout: string; - stderr: string; - process: Process; -}; - -type ProcessStart = Result; - -function* inspector(stream: Stream) { - let starts: ProcessStart[] = []; - - let expected = 0; - - yield* spawn(function* () { - for (let { result } of yield* each(stream)) { - if (result.ok) { - let process = result.value; - let start = { - stdout: "", - stderr: "", - process: result.value, - }; - starts.push(Ok(start)); - yield* spawn(function* () { - for (let chunk of yield* each(process.stdout)) { - start.stdout += String(chunk); - yield* each.next(); - } - }); - yield* spawn(function* () { - for (let chunk of yield* each(process.stderr)) { - start.stderr += String(chunk); - yield* each.next(); - } - }); - } else { - starts.push(result); - } - - yield* each.next(); - } - }); - - let inspector = { - starts, - *expectNext(): Operation { - let initial = expected; - for (let i = 0; i < 500; i++) { - if (initial < starts.length) { - yield* sleep(10); - expected = starts.length; - let result = inspector.starts[inspector.starts.length - 1]; - if (result.ok) { - return result.value; - } else { - throw new Error( - `expected successful start, but failed: ${result.error}`, - ); - } - } else { - yield* sleep(10); - } - } - throw new Error(`expecting a sucessful start but it never appeared.`); - }, - *expectNoRestart() { - let prexisting = inspector.starts.length; - yield* sleep(200); - let restarts = inspector.starts.length - prexisting; - assert( - restarts === 0, - `expected no process restarts to have happened, but instead there were: ${restarts}`, - ); - }, - }; - return inspector; -} - -// function* ntimeout(op: () => Operation): Operation { -// let result = yield* timebox(1000, op); -// if (result.timeout) { -// throw new Error(`timeout`); -// } else { -// return result.value; -// } -// }; diff --git a/watch/watch.ts b/watch/watch.ts index 0b413dfc..9dd7b279 100644 --- a/watch/watch.ts +++ b/watch/watch.ts @@ -12,13 +12,14 @@ import { type Result, spawn, type Stream, + until, withResolvers, } from "effection"; import { default as createIgnore } from "ignore"; import { pipe } from "@gordonb/pipe"; import { readFile } from "node:fs/promises"; +import { exec, type ExecOptions, type Process } from "@effectionx/process"; -import { type Process, useProcess } from "./child-process.ts"; import { debounce } from "./stream-helpers.ts"; /** @@ -60,6 +61,11 @@ export interface WatchOptions { * @ignore */ event?: "all" | "change"; + + /** + * Options passed to process exec + */ + execOptions?: ExecOptions; } /** @@ -119,13 +125,14 @@ export function watch(options: WatchOptions): Stream { }); let changes = yield* pipe(input, debounce(100)); + let subscription = yield* starts; yield* spawn(function* () { while (true) { let task = yield* spawn(function* () { let restarting = withResolvers(); try { - let process = yield* useProcess(options.cmd); + let process = yield* exec(options.cmd, options.execOptions); yield* starts.send({ result: Ok(process), restarting: restarting.operation, @@ -136,7 +143,6 @@ export function watch(options: WatchOptions): Stream { restarting: restarting.operation, }); } - yield* changes.next(); restarting.resolve(); }); @@ -145,9 +151,9 @@ export function watch(options: WatchOptions): Stream { }); try { - yield* provide(yield* starts); + yield* provide(subscription); } finally { - yield* call(() => watcher.close()); + yield* until(watcher.close()); } }); } @@ -156,9 +162,7 @@ export function watch(options: WatchOptions): Stream { * locate a `.gitignore` file if it exists and use it to filter * out any change events against paths that are matched by it */ -function* findIgnores(path: string): Operation< - (path: string) => boolean -> { +function* findIgnores(path: string): Operation<(path: string) => boolean> { let gitignore = join(path, ".gitignore"); if (yield* call(() => exists(gitignore))) { let ignores = createIgnore(); @@ -175,6 +179,6 @@ function* findIgnores(path: string): Operation< function fresh(staletime: number): (args: EmitArgsWithName) => boolean { return ([, , stats]) => { - return !stats || (Date.now() - stats.mtimeMs) < staletime; + return !stats || Date.now() - stats.mtimeMs < staletime; }; } diff --git a/websocket/deno.json b/websocket/deno.json index a90bda2d..2f14e8af 100644 --- a/websocket/deno.json +++ b/websocket/deno.json @@ -1,9 +1,11 @@ { "name": "@effectionx/websocket", - "version": "2.1.0", + "version": "2.0.0", "exports": "./mod.ts", "license": "MIT", "imports": { - "effection": "npm:effection@4.0.0-beta.2" + "effection": "npm:effection@^3", + "@std/expect": "jsr:@std/expect@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2" } } diff --git a/websocket/websocket.test.ts b/websocket/websocket.test.ts index 98b5f9c3..cb29c452 100644 --- a/websocket/websocket.test.ts +++ b/websocket/websocket.test.ts @@ -1,11 +1,10 @@ -import { describe, it } from "bdd"; -import { expect } from "expect"; +import { describe, it } from "@effectionx/bdd"; +import { expect } from "@std/expect"; import { call, createQueue, type Operation, resource, - run, type Subscription, suspend, useScope, @@ -14,59 +13,52 @@ import { import { useWebSocket, type WebSocketResource } from "./websocket.ts"; describe("WebSocket", () => { - it("can send messages from the client to the server", async () => { - await run(function* () { - let [client, server] = yield* useTestingPair(); + it("can send messages from the client to the server", function* () { + let [client, server] = yield* useTestingPair(); - let subscription = yield* server.socket; + let subscription = yield* server.socket; - client.socket.send("hello from client"); + client.socket.send("hello from client"); - let { value } = yield* subscription.next(); + let { value } = yield* subscription.next(); - expect(value).toMatchObject({ data: "hello from client" }); - }); + expect(value).toMatchObject({ data: "hello from client" }); }); - it("can send messages from the server to the client", async () => { - await run(function* () { - let [client, server] = yield* useTestingPair(); + it("can send messages from the server to the client", function* () { + let [client, server] = yield* useTestingPair(); - let subscription = yield* client.socket; + let subscription = yield* client.socket; - server.socket.send("hello from server"); + server.socket.send("hello from server"); - let { value } = yield* subscription.next(); + let { value } = yield* subscription.next(); - expect(value).toMatchObject({ data: "hello from server" }); - }); + expect(value).toMatchObject({ data: "hello from server" }); }); - it("closes the client when the server closes", async () => { - await run(function* () { - let [client, server] = yield* useTestingPair(); - let messages = yield* client.socket; + it("closes the client when the server closes", function* () { + let [client, server] = yield* useTestingPair(); + let messages = yield* client.socket; - server.close(); + server.close(); - let event = yield* drain(messages); + let event = yield* drain(messages); - expect(event.type).toEqual("close"); - expect(event.wasClean).toEqual(true); - }); + expect(event.type).toEqual("close"); + expect(event.wasClean).toEqual(true); }); - it("closes the server when the client closes", async () => { - await run(function* () { - let [client, server] = yield* useTestingPair(); - let messages = yield* server.socket; - client.close(); + it("closes the server when the client closes", function* () { + let [client, server] = yield* useTestingPair(); + let messages = yield* server.socket; - let event = yield* drain(messages); + client.close(); - expect(event.type).toEqual("close"); - expect(event.wasClean).toEqual(true); - }); + let event = yield* drain(messages); + + expect(event.type).toEqual("close"); + expect(event.wasClean).toEqual(true); }); }); @@ -79,40 +71,43 @@ export interface TestingPairOptions { fail?: Response; } -function useTestingPair( - { fail }: TestingPairOptions = {}, -): Operation<[TestSocket, TestSocket]> { +function useTestingPair({ fail }: TestingPairOptions = {}): Operation< + [TestSocket, TestSocket] +> { return resource(function* (provide) { let sockets = createQueue(); let scope = yield* useScope(); let server = yield* call(() => - Deno.serve({ - port: 9901, - onListen() {}, - }, (req) => { - if (req.headers.get("upgrade") != "websocket") { - return new Response(null, { status: 501 }); - } else if (fail) { - return fail; - } - const { socket, response } = Deno.upgradeWebSocket(req); - - scope.run(function* () { - sockets.add({ - close: () => socket.close(), - socket: yield* useWebSocket(() => socket), + Deno.serve( + { + port: 9901, + onListen() {}, + }, + (req) => { + if (req.headers.get("upgrade") != "websocket") { + return new Response(null, { status: 501 }); + } else if (fail) { + return fail; + } + const { socket, response } = Deno.upgradeWebSocket(req); + + scope.run(function* () { + sockets.add({ + close: () => socket.close(), + socket: yield* useWebSocket(() => socket), + }); + yield* suspend(); }); - yield* suspend(); - }); - return response; - }) + return response; + }, + ) ); let client = new WebSocket( - `ws://${server.addr.hostname}:${server.addr.port}`, + `ws://localhost:${server.addr.port}`, ); let next = yield* sockets.next(); diff --git a/worker/deno.json b/worker/deno.json index 8219e59b..5d684b28 100644 --- a/worker/deno.json +++ b/worker/deno.json @@ -1,13 +1,16 @@ { "name": "@effectionx/worker", - "version": "0.2.0", + "version": "0.1.0", "license": "MIT", "exports": "./mod.ts", "imports": { - "effection": "npm:effection@4.0.0-beta.2", - "@std/assert": "jsr:@std/assert@1", - "@effectionx/timebox": "npm:@effectionx/timebox@^0.2.0", - "@std/path": "jsr:@std/path@1", - "@std/fs": "jsr:@std/fs@1" + "effection": "npm:effection@^3", + "@std/assert": "jsr:@std/assert@^1", + "@std/expect": "jsr:@std/expect@^1", + "@effectionx/timebox": "npm:@effectionx/timebox@^0.1.0", + "@std/path": "jsr:@std/path@^1", + "@std/fs": "jsr:@std/fs@^1", + "@effectionx/bdd": "jsr:@effectionx/bdd@0.2.2", + "@effection/signals": "npm:@effectionx/signals@0.3.0" } } diff --git a/worker/message-channel.ts b/worker/message-channel.ts new file mode 100644 index 00000000..dc04fb18 --- /dev/null +++ b/worker/message-channel.ts @@ -0,0 +1,13 @@ +import { type Operation, resource } from "effection"; + +export function useMessageChannel(): Operation { + return resource(function* (provide) { + let channel = new MessageChannel(); + try { + yield* provide(channel); + } finally { + channel.port1.close(); + channel.port2.close(); + } + }); +} diff --git a/worker/mod.ts b/worker/mod.ts index d28a00df..8b00b4c7 100644 --- a/worker/mod.ts +++ b/worker/mod.ts @@ -1 +1,2 @@ export * from "./worker.ts"; +export * from "./worker-main.ts"; diff --git a/worker/test-assets/boom-result-worker.ts b/worker/test-assets/boom-result-worker.ts index d133e7bb..a1088df6 100644 --- a/worker/test-assets/boom-result-worker.ts +++ b/worker/test-assets/boom-result-worker.ts @@ -1,4 +1,4 @@ -import { workerMain } from "../worker.ts"; +import { workerMain } from "../worker-main.ts"; await workerMain(function* ({ data }) { throw new Error(String(data)); diff --git a/worker/test-assets/boom-worker.ts b/worker/test-assets/boom-worker.ts index 91301f9d..69f07012 100644 --- a/worker/test-assets/boom-worker.ts +++ b/worker/test-assets/boom-worker.ts @@ -1,4 +1,4 @@ -import { workerMain } from "../worker.ts"; +import { workerMain } from "../worker-main.ts"; await workerMain(function* ({ messages }) { yield* messages.forEach(function* () { diff --git a/worker/test-assets/counter-worker.ts b/worker/test-assets/counter-worker.ts index 07b84d51..f448202c 100644 --- a/worker/test-assets/counter-worker.ts +++ b/worker/test-assets/counter-worker.ts @@ -1,4 +1,4 @@ -import { workerMain } from "../worker.ts"; +import { workerMain } from "../worker-main.ts"; await workerMain( function* ({ messages, data: initial }) { diff --git a/worker/test-assets/echo-worker.ts b/worker/test-assets/echo-worker.ts index d9a8b102..ae94b017 100644 --- a/worker/test-assets/echo-worker.ts +++ b/worker/test-assets/echo-worker.ts @@ -1,4 +1,4 @@ -import { workerMain } from "../worker.ts"; +import { workerMain } from "../worker-main.ts"; await workerMain(function* ({ messages }) { yield* messages.forEach(function* (message) { diff --git a/worker/test-assets/result-worker.ts b/worker/test-assets/result-worker.ts index 689e9f52..27d90e4e 100644 --- a/worker/test-assets/result-worker.ts +++ b/worker/test-assets/result-worker.ts @@ -1,4 +1,4 @@ -import { workerMain } from "../worker.ts"; +import { workerMain } from "../worker-main.ts"; await workerMain(function* ({ data }) { return data; diff --git a/worker/test-assets/shutdown-worker.ts b/worker/test-assets/shutdown-worker.ts index 26dc709e..80393a81 100644 --- a/worker/test-assets/shutdown-worker.ts +++ b/worker/test-assets/shutdown-worker.ts @@ -1,5 +1,5 @@ -import { call, suspend } from "effection"; -import { workerMain } from "../worker.ts"; +import { suspend, until } from "effection"; +import { workerMain } from "../worker-main.ts"; export interface ShutdownWorkerParams { startFile: string; @@ -11,9 +11,9 @@ await workerMain(function* ({ data }) { let params = data as ShutdownWorkerParams; let { startFile, endFile, endText } = params; try { - yield* call(() => Deno.writeTextFile(startFile, "started")); + yield* until(Deno.writeTextFile(startFile, "started")); yield* suspend(); } finally { - yield* call(() => Deno.writeTextFile(endFile, endText)); + yield* until(Deno.writeTextFile(endFile, endText)); } }); diff --git a/worker/test-assets/suspend-worker.ts b/worker/test-assets/suspend-worker.ts index cd7ab1a0..a38179ff 100644 --- a/worker/test-assets/suspend-worker.ts +++ b/worker/test-assets/suspend-worker.ts @@ -1,4 +1,4 @@ import { suspend } from "effection"; -import { workerMain } from "../worker.ts"; +import { workerMain } from "../worker-main.ts"; await workerMain(suspend); diff --git a/worker/types.ts b/worker/types.ts new file mode 100644 index 00000000..80d2dd2c --- /dev/null +++ b/worker/types.ts @@ -0,0 +1,35 @@ +import type { Operation } from "effection"; + +export type WorkerControl = { + type: "init"; + data: TData; +} | { + type: "send"; + value: TSend; + response: MessagePort; +} | { + type: "close"; +}; + +export interface WorkerMainOptions { + /** + * Namespace that provides APIs for working with incoming messages + */ + messages: WorkerMessages; + /** + * Initial data received by the worker from the main thread used for initialization. + */ + data: TData; +} + +/** + * Object that represents messages the main thread + * sends to the worker. It provides function for + * handling messages. + * + * @template TSend - value main thread will send to the worker + * @template TRecv - value main thread will receive from the worker + */ +export interface WorkerMessages { + forEach(fn: (message: TSend) => Operation): Operation; +} diff --git a/worker/worker-main.ts b/worker/worker-main.ts new file mode 100644 index 00000000..ec78eadc --- /dev/null +++ b/worker/worker-main.ts @@ -0,0 +1,226 @@ +import type { ValueSignal } from "@effectionx/signals"; +import { + createSignal, + each, + Err, + main, + Ok, + on, + type Operation, + resource, + spawn, + type Task, +} from "effection"; + +import type { WorkerControl, WorkerMainOptions } from "./types.ts"; + +/** + * Entrypoint used in the worker that estaliblishes communication + * with the main thread. It can be used to return a value, + * respond to messages or both. + * + * @example Returning a value + * ```ts + * import { workerMain } from "../worker.ts"; + * + * await workerMain(function* ({ data }) { + * return data; + * }); + * ``` + * + * @example Responding to messages + * ```ts + * import { workerMain } from "../worker.ts"; + * + * await workerMain(function* ({ messages }) { + * yield* messages.forEach(function* (message) { + * return message; + * }); + * }); + * ``` + * + * @example Responding to messages and return a value + * ```ts + * import { workerMain } from "../worker.ts"; + * + * await workerMain( + * function* ({ messages, data: initial }) { + * let counter = initial; + * + * yield* messages.forEach(function* (message) { + * counter += message; + * return counter; // returns a value after each message + * }); + * + * return counter; // returns the final value + * }, + * ); + * ``` + * + * @template TSend - value main thread will send to the worker + * @template TRecv - value main thread will receive from the worker + * @template TReturn - worker operation return value + * @template TData - data passed from the main thread to the worker during initialization + * @param {(options: WorkerMainOptions) => Operation} body + * @returns {Promise} + */ +export async function workerMain( + body: (options: WorkerMainOptions) => Operation, +): Promise { + await main(function* () { + let sent = createSignal<{ value: TSend; response: MessagePort }>(); + let worker = yield* createWorkerStatesSignal(); + + yield* spawn(function* () { + for (const message of yield* each(on(self, "message"))) { + const control: WorkerControl = message.data; + switch (control.type) { + case "init": { + worker.start( + yield* spawn(function* () { + try { + let value = yield* body({ + data: control.data, + messages: { + *forEach(fn: (value: TSend) => Operation) { + for (let { value, response } of yield* each(sent)) { + yield* spawn(function* () { + try { + let result = yield* fn(value); + response.postMessage(Ok(result)); + } catch (error) { + response.postMessage(Err(error as Error)); + } + }); + yield* each.next(); + } + }, + }, + }); + + worker.complete(value); + } catch (error) { + worker.crash(error as Error); + } + }), + ); + break; + } + case "send": { + let { value, response } = control; + sent.send({ value, response }); + break; + } + case "close": { + const current = worker.valueOf(); + if (current.type === "running") { + yield* current.task.halt(); + worker.interrupt(); + } + } + } + + yield* each.next(); + } + }); + + for (const state of yield* each(worker)) { + if (state.type === "new") { + postMessage({ type: "open" }); + } else if (state.type === "interrupted" || state.type === "error") { + postMessage({ type: "close", result: Err(state.error) }); + } else if (state.type === "complete") { + postMessage({ type: "close", result: Ok(state.value) }); + } + yield* each.next(); + } + }); +} + +type New = { type: "new" }; +type Running = { type: "running"; task: Task }; +type Complete = { type: "complete"; value: unknown }; +type Errored = { type: "error"; error: Error }; +type Interrupted = { type: "interrupted"; error: Error }; + +type WorkerState = New | Running | Complete | Errored | Interrupted; + +interface WorkerStateSignal extends ValueSignal { + state: WorkerState["type"]; + start(task: Task): Running | Complete; + complete(value: unknown): Complete; + crash(error: Error): Errored; + interrupt(): Interrupted; +} + +export function createWorkerStatesSignal(): Operation { + return resource(function* (provide) { + let ref: { current: WorkerState } = { + current: { type: "new" }, + }; + const signal = createSignal(); + + const set: WorkerStateSignal["set"] = (value) => { + signal.send(ref.current = value); + return value; + }; + + const update: WorkerStateSignal["update"] = (updater) => + set(updater(ref.current)); + + const interrupt: WorkerStateSignal["interrupt"] = () => { + let next: Interrupted = { + type: "interrupted", + error: new Error(`worker terminated`), + }; + set(next); + return next; + }; + + try { + yield* provide({ + *[Symbol.iterator]() { + let subscription = yield* signal; + signal.send(ref.current); + return subscription; + }, + get state() { + return ref.current.type; + }, + set, + update, + valueOf() { + return ref.current; + }, + start(task) { + if (ref.current.type === "complete") { + return ref.current; + } else { + const next: Running = { type: "running", task }; + set(next); + return next; + } + }, + complete(value) { + let next: Complete = { type: "complete", value }; + set(next); + return next; + }, + crash(error) { + let next: Errored = { type: "error", error }; + set(next); + return next; + }, + interrupt: interrupt, + }); + } finally { + if (ref.current.type === "running") { + interrupt(); + } + } + }); +} + +function postMessage(message: unknown): void { + self.postMessage(message); +} diff --git a/worker/worker.test.ts b/worker/worker.test.ts index eb884e5e..0f1bf10d 100644 --- a/worker/worker.test.ts +++ b/worker/worker.test.ts @@ -1,69 +1,77 @@ -import { describe, it } from "bdd"; -import { expect } from "expect"; -import { emptyDir, exists } from "@std/fs"; -import { join } from "@std/path"; +import { beforeEach, describe, it } from "@effectionx/bdd"; import { timebox } from "@effectionx/timebox"; -import { call, run, scoped, sleep, spawn, suspend } from "effection"; - -import { useWorker } from "./worker.ts"; import { assert } from "@std/assert"; +import { expect } from "@std/expect"; +import { emptyDir, exists } from "@std/fs"; +import { fromFileUrl, join } from "@std/path"; +import { scoped, sleep, spawn, suspend, until } from "effection"; + import type { ShutdownWorkerParams } from "./test-assets/shutdown-worker.ts"; +import { useWorker } from "./worker.ts"; describe("worker", () => { - it("sends and receive messages in synchrony", async () => { - await run(function* () { - let worker = yield* useWorker( - import.meta.resolve("./test-assets/echo-worker.ts"), - { type: "module" }, - ); - - let result = yield* worker.send("hello world"); - expect(result).toEqual("hello world"); - }); - }); - it("will raise an exception if an exception happens on the remote side", async () => { + it("sends and receive messages in synchrony", function* () { expect.assertions(1); - let task = run(function* () { - let worker = yield* useWorker( - import.meta.resolve("./test-assets/boom-worker.ts"), - { type: "module" }, - ); + let worker = yield* useWorker( + import.meta.resolve("./test-assets/echo-worker.ts"), + { type: "module" }, + ); + let result = yield* worker.send("hello world"); + expect(result).toEqual("hello world"); + }); + it("will raise an exception if an exception happens on the remote side", function* () { + expect.assertions(2); + let worker = yield* useWorker( + import.meta.resolve("./test-assets/boom-worker.ts"), + { type: "module" }, + ); + + try { yield* worker.send(); - }); - await expect(task).rejects.toMatchObject({ message: "boom!" }); + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect(e).toMatchObject({ message: "boom!" }); + } }); - it("produces its return value", async () => { - await run(function* () { - let worker = yield* useWorker( - import.meta.resolve("./test-assets/result-worker.ts"), - { type: "module", data: "this is the worker result" }, - ); - - expect(yield* worker).toEqual("this is the worker result"); - expect(yield* worker).toEqual("this is the worker result"); - }); + it("produces its return value", function* () { + expect.assertions(2); + let worker = yield* useWorker( + import.meta.resolve("./test-assets/result-worker.ts"), + { type: "module", data: "this is the worker result" }, + ); + + expect(yield* worker).toEqual("this is the worker result"); + expect(yield* worker).toEqual("this is the worker result"); }); - it("raises an exception if the worker raises one", async () => { - expect.assertions(1); - let task = run(function* () { - let worker = yield* useWorker( - import.meta.resolve("./test-assets/boom-result-worker.ts"), - { type: "module", data: "boom!" }, - ); - - let result = yield* worker; - expect(result).toEqual("hello world"); - }); - await expect(task).rejects.toMatchObject({ message: "boom!" }); + it("raises an exception if the worker raises one", function* () { + expect.assertions(2); + let worker = yield* useWorker( + import.meta.resolve("./test-assets/boom-result-worker.ts"), + { type: "module", data: "boom!" }, + ); + + try { + yield* worker; + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect(e).toMatchObject({ message: "boom!" }); + } }); - it("shuts down gracefully", async () => { - let dir = new URL(import.meta.resolve("./test-tmp")).pathname; - await emptyDir(dir); - let startFile = join(dir, "started.txt"); - let endFile = join(dir, "ended.txt"); - let url = import.meta.resolve("./test-assets/shutdown-worker.ts"); - await run(function* () { + describe("shutdown", () => { + let startFile: string; + let endFile: string; + let url: string; + + beforeEach(function* () { + let dir = fromFileUrl(import.meta.resolve("./test-tmp")); + yield* until(emptyDir(dir)); + startFile = join(dir, "started.txt"); + endFile = join(dir, "ended.txt"); + url = import.meta.resolve("./test-assets/shutdown-worker.ts"); + }); + + it("shuts down gracefully", function* () { let task = yield* spawn(function* () { yield* useWorker(url, { type: "module", @@ -79,7 +87,7 @@ describe("worker", () => { let started = yield* timebox(10_000, function* () { while (true) { yield* sleep(1); - if (yield* call(() => exists(startFile))) { + if (yield* until(exists(startFile))) { break; } } @@ -87,59 +95,70 @@ describe("worker", () => { assert(!started.timeout, "worker did not start after 10s"); yield* task.halt(); - }); - expect(await exists(endFile)).toEqual(true); - expect(await Deno.readTextFile(endFile)).toEqual("goodbye cruel world!"); + expect(yield* until(exists(endFile))).toEqual(true); + expect(yield* until(Deno.readTextFile(endFile))).toEqual( + "goodbye cruel world!", + ); + }); }); - it("becomes halted if you try and await its value out of scope", async () => { - let url = import.meta.resolve("./test-assets/suspend-worker.ts"); - let task = run(function* () { + it( + "becomes halted if you try and await its value out of scope", + function* () { + let url = import.meta.resolve("./test-assets/suspend-worker.ts"); let worker = yield* scoped(function* () { return yield* useWorker(url, { type: "module" }); }); - yield* worker; - }); - await expect(task).rejects.toMatchObject({ message: "worker terminated" }); - }); + try { + yield* worker; + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect(e).toMatchObject({ message: "worker terminated" }); + } + }, + ); + + it("supports stateful operations", function* () { + expect.assertions(3); - it("supports stateful operations", async () => { let url = import.meta.resolve("./test-assets/counter-worker.ts"); - expect.assertions(3); - await run(function* () { - let worker = yield* useWorker(url, { type: "module", data: 2 }); + let worker = yield* useWorker(url, { type: "module", data: 2 }); - expect(yield* worker.send(10)).toEqual(12); + expect(yield* worker.send(10)).toEqual(12); - expect(yield* worker.send(-5)).toEqual(7); + expect(yield* worker.send(-5)).toEqual(7); - expect(yield* worker.send(35)).toEqual(42); - }); + expect(yield* worker.send(35)).toEqual(42); }); - it.skip("crashes if there is an uncaught error in the worker", async () => { + it.skip("crashes if there is an uncaught error in the worker", function* () { let crash = import.meta.resolve("./test-assets/crash-worker.ts"); - let task = run(function* () { - let worker = yield* useWorker(crash, { name: "crash", type: "module" }); + let worker = yield* useWorker(crash, { name: "crash", type: "module" }); + try { yield* worker; - }); - await expect(task).rejects.toMatchObject({ message: "boom!" }); + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect(e).toMatchObject({ message: "boom!" }); + } }); - it.skip("crashes if the worker module cannot be found", async () => { + + it.skip("crashes if the worker module cannot be found", function* () { let crash = import.meta.resolve("./test-assets/non-existent-worker.ts"); - let task = run(function* () { - let worker = yield* useWorker(crash, { name: "crash", type: "module" }); + let worker = yield* useWorker(crash, { name: "crash", type: "module" }); + try { yield* worker; - }); - await expect(task).rejects.toMatchObject({ message: "boom!" }); + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect(e).toMatchObject({ message: "boom!" }); + } }); - it.skip("crashes if there is a message error from the main thread", async () => { + it.skip("crashes if there is a message error from the main thread", function* () { // don't know how to reproduce this }); - it.skip("crashes if there is a message error from the worker thread", async () => { + it.skip("crashes if there is a message error from the worker thread", function* () { // don't know how to trigger }); }); diff --git a/worker/worker.ts b/worker/worker.ts index 41e559cc..edf22608 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -1,36 +1,17 @@ import { assert } from "@std/assert"; import { - createSignal, - each, Err, - main, Ok, on, once, type Operation, resource, type Result, - scoped, spawn, withResolvers, } from "effection"; -export interface WorkerResource - extends Operation { - send(data: TSend): Operation; -} - -/** - * Object that represents messages the main thread - * sends to the worker. It provides function for - * handling messages. - * - * @template TSend - value main thread will send to the worker - * @template TRecv - value main thread will receive from the worker - */ -export interface WorkerMessages { - forEach(fn: (message: TSend) => Operation): Operation; -} +import { useMessageChannel } from "./message-channel.ts"; /** * Argument received by workerMain function @@ -39,123 +20,9 @@ export interface WorkerMessages { * @template TRecv - value main thread will receive from the worker * @template TData - data passed from the main thread to the worker during initialization */ -export interface WorkerMainOptions { - /** - * Namespace that provides APIs for working with incoming messages - */ - messages: WorkerMessages; - /** - * Initial data received by the worker from the main thread used for initialization. - */ - data: TData; -} - -/** - * Entrypoint used in the worker that estaliblishes communication - * with the main thread. It can be used to return a value, - * respond to messages or both. - * - * @example Returning a value - * ```ts - * import { workerMain } from "../worker.ts"; - * - * await workerMain(function* ({ data }) { - * return data; - * }); - * ``` - * - * @example Responding to messages - * ```ts - * import { workerMain } from "../worker.ts"; - * - * await workerMain(function* ({ messages }) { - * yield* messages.forEach(function* (message) { - * return message; - * }); - * }); - * ``` - * - * @example Responding to messages and return a value - * ```ts - * import { workerMain } from "../worker.ts"; - * - * await workerMain( - * function* ({ messages, data: initial }) { - * let counter = initial; - * - * yield* messages.forEach(function* (message) { - * counter += message; - * return counter; // returns a value after each message - * }); - * - * return counter; // returns the final value - * }, - * ); - * ``` - * - * @template TSend - value main thread will send to the worker - * @template TRecv - value main thread will receive from the worker - * @template TReturn - worker operation return value - * @template TData - data passed from the main thread to the worker during initialization - * @param {(options: WorkerMainOptions) => Operation} body - * @returns {Promise} - */ -export async function workerMain( - body: (options: WorkerMainOptions) => Operation, -): Promise { - await main(function* () { - let sent = createSignal<{ value: TSend; response: MessagePort }>(); - let controls = yield* on(self, "message"); - let outcome = withResolvers>(); - - self.postMessage({ type: "open" }); - - let result = yield* scoped(function* () { - yield* spawn(function* () { - let next = yield* controls.next(); - while (true) { - let control: WorkerControl = next.value.data; - if (control.type === "init") { - yield* spawn(function* () { - try { - let value = yield* body({ - data: control.data, - messages: { - *forEach(fn: (value: TSend) => Operation) { - for (let { value, response } of yield* each(sent)) { - yield* spawn(function* () { - try { - let result = yield* fn(value); - response.postMessage(Ok(result)); - } catch (error) { - response.postMessage(Err(error as Error)); - } - }); - yield* each.next(); - } - }, - }, - }); - - outcome.resolve(Ok(value)); - } catch (error) { - outcome.resolve(Err(error as Error)); - } - }); - } else if (control.type === "send") { - let { value, response } = control; - sent.send({ value, response }); - } else if (control.type === "close") { - outcome.resolve(Err(new Error(`worker terminated`))); - } - next = yield* controls.next(); - } - }); - - return yield* outcome.operation; - }); - self.postMessage({ type: "close", result }); - }); +export interface WorkerResource + extends Operation { + send(data: TSend): Operation; } /** @@ -275,35 +142,12 @@ export function useWorker( }); } -type WorkerControl = { - type: "init"; - data: TData; -} | { - type: "send"; - value: TSend; - response: MessagePort; -} | { - type: "close"; -}; - -function useMessageChannel(): Operation { - return resource(function* (provide) { - let channel = new MessageChannel(); - try { - yield* provide(channel); - } finally { - channel.port1.close(); - channel.port2.close(); - } - }); -} - function settled(operation: Operation): Operation> { return { *[Symbol.iterator]() { try { yield* operation; - return Ok(); + return Ok(void 0); } catch (error) { return Err(error as Error); }