From fb100eb7a42b1f1ca1e1cf9032e4d942b1469cfd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Apr 2025 18:34:43 -0400 Subject: [PATCH 001/119] Added until function --- lib/call.ts | 16 ++++++++++++++++ test/call.test.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/call.ts b/lib/call.ts index 50cd79c76..9a1713367 100644 --- a/lib/call.ts +++ b/lib/call.ts @@ -101,6 +101,22 @@ export function call( }; } +/** + * It can be used to treat a promise as an operation. This function + * is a replacement to the v3 deprecated `call(promise)` function form. + * + * @example + * ```js + * let response = yield* until(fetch('https://google.com')); + * ``` + * @template {T} + * @param promise + * @returns {Operation} + */ +export function until(promise: PromiseLike): Operation { + return call(async () => await promise); +} + function isPromise( target: Operation | Promise | T, ): target is Promise { diff --git a/test/call.test.ts b/test/call.test.ts index 19229097a..42314f3c6 100644 --- a/test/call.test.ts +++ b/test/call.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "./suite.ts"; -import { call, run } from "../mod.ts"; +import { call, run, until } from "../mod.ts"; describe("call", () => { it("evaluates an operation function", async () => { @@ -22,6 +22,10 @@ describe("call", () => { )).resolves.toEqual(42); }); + it("evaluates a promise directly with `until`", async () => { + await expect(run(() => until(Promise.resolve(42)))).resolves.toEqual(42); + }); + it("evaluates a no-arg async function", async () => { await expect(run(() => call(() => Promise.resolve(42)))).resolves.toEqual( 42, From d63b7d9b5bef67258567affd4910337e72962916 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Apr 2025 18:06:08 -0400 Subject: [PATCH 002/119] Updated documentation --- docs/async-rosetta-stone.mdx | 19 ++++++++++--------- docs/scope.mdx | 6 +++--- docs/tutorial.mdx | 10 +++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/async-rosetta-stone.mdx b/docs/async-rosetta-stone.mdx index c34a5d92c..f19e99cdb 100644 --- a/docs/async-rosetta-stone.mdx +++ b/docs/async-rosetta-stone.mdx @@ -115,12 +115,12 @@ To use an operation: let result = yield* operation; ``` -To convert from a promise to an operation, use [`call()`][call] +To convert from a promise to an operation, use [`until()`][until] ```js -import { call } from 'effection'; +import { until } from 'effection'; -let operation = call(promise); +let operation = until(promise); ``` to convert from an operation to a promise, use [`run()`][run] or [`Scope.run`][scope-run] @@ -316,9 +316,10 @@ To convert an `AsyncIterator` to a `Subscription`, use the let subscription = subscribe(asyncIterator); ``` -[call]: https://deno.land/x/effection/mod.ts?s=call -[run]: https://deno.land/x/effection/mod.ts?s=run -[scope-run]: https://deno.land/x/effection/mod.ts?s=Scope#method_run_0 -[each]: https://deno.land/x/effection/mod.ts?s=each -[stream]: https://deno.land/x/effection/mod.ts?s=stream -[subscribe]: https://deno.land/x/effection/mod.ts?s=subscribe +[call]: /api/v3/call +[until]: /api/v3/until +[run]: /api/v3/run +[scope-run]: /api/v3/Scope#interface_Scope-methods +[each]: /api/v3/each +[stream]: /api/v3/stream +[subscribe]: /api/v3/subscribe diff --git a/docs/scope.mdx b/docs/scope.mdx index 77fc46317..3d6a3f208 100644 --- a/docs/scope.mdx +++ b/docs/scope.mdx @@ -290,7 +290,7 @@ Effection, and so we have to explicitly connect each request to an operation. ```js -import { call, ensure, main, useScope, useAbortSignal, suspend } from "effection"; +import { until, ensure, main, useScope, useAbortSignal, suspend } from "effection"; import express from "express"; await main(function*() { @@ -301,8 +301,8 @@ await main(function*() { // use the scope to spawn an operation within it. return await scope.run(function*() { let signal = yield* useAbortSignal(); - let response = yield* call(() => fetch(`https://google.com?q=${req.params.q}`, { signal })); - res.send(yield* call(() => response.text())); + let response = yield* until(fetch(`https://google.com?q=${req.params.q}`, { signal })); + res.send(yield* until(response.text())); }); }); diff --git a/docs/tutorial.mdx b/docs/tutorial.mdx index 26edccbf6..988110d92 100644 --- a/docs/tutorial.mdx +++ b/docs/tutorial.mdx @@ -1,14 +1,14 @@ Let's write our first program using Effection. ``` javascript -import { call, useAbortSignal } from "effection"; +import { until, useAbortSignal } from "effection"; export function* fetchWeekDay(timezone) { let signal = yield* useAbortSignal(); - let response = yield* call(fetch(`http://worldclockapi.com/api/json/${timezone}/now`, { signal })); + let response = yield* until(fetch(`http://worldclockapi.com/api/json/${timezone}/now`, { signal })); - let time = yield* call(response.json()); + let time = yield* until(response.json()); return time.dayOfTheWeek; } @@ -50,7 +50,7 @@ operations out of existing ones. For example, we can add a time out of for that matter) by wrapping it in a `withTimeout` operation. ```javascript -import { main, call, race } from 'effection'; +import { main, until, race } from 'effection'; import { fetchWeekDay } from './fetch-week-day'; await main(function*() { @@ -59,7 +59,7 @@ await main(function*() { }); function withTimeout(operation, delay) { - return race([operation, call(function*() { + return race([operation, until(function*() { yield* sleep(delay); throw new Error(`timeout!`); })]); From 49395b8e3901344eb4ef5cb492674edc6f8a859f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Apr 2025 18:16:51 -0400 Subject: [PATCH 003/119] Updated all of the API links --- docs/actions.mdx | 4 ++-- docs/async-rosetta-stone.mdx | 14 +++++++------- docs/collections.mdx | 14 +++++++------- docs/errors.mdx | 2 +- docs/events.mdx | 4 ++-- docs/operations.mdx | 6 +++--- docs/resources.mdx | 6 +++--- docs/scope.mdx | 8 ++++---- docs/spawn.mdx | 4 ++-- docs/typescript.mdx | 2 +- 10 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/actions.mdx b/docs/actions.mdx index 0f2d8b225..14e6dc7d6 100644 --- a/docs/actions.mdx +++ b/docs/actions.mdx @@ -141,5 +141,5 @@ function* fetch(url) { [cleanup]: ./operations#cleanup [scope]: ./scope [spawn-suspend]: ./spawn#suspend -[action]: https://deno.land/x/effection/mod.ts?s=action -[all]: https://deno.land/x/effection/mod.ts?s=all +[action]: /api/v4/action +[all]: /api/v4/all diff --git a/docs/async-rosetta-stone.mdx b/docs/async-rosetta-stone.mdx index f19e99cdb..62edce077 100644 --- a/docs/async-rosetta-stone.mdx +++ b/docs/async-rosetta-stone.mdx @@ -316,10 +316,10 @@ To convert an `AsyncIterator` to a `Subscription`, use the let subscription = subscribe(asyncIterator); ``` -[call]: /api/v3/call -[until]: /api/v3/until -[run]: /api/v3/run -[scope-run]: /api/v3/Scope#interface_Scope-methods -[each]: /api/v3/each -[stream]: /api/v3/stream -[subscribe]: /api/v3/subscribe +[call]: /api/v4/call +[until]: /api/v4/until +[run]: /api/v4/run +[scope-run]: /api/v4/Scope#interface_Scope-methods +[each]: /api/v4/each +[stream]: /api/v4/stream +[subscribe]: /api/v4/subscribe diff --git a/docs/collections.mdx b/docs/collections.mdx index 656b936ee..cf1bd0689 100644 --- a/docs/collections.mdx +++ b/docs/collections.mdx @@ -387,14 +387,14 @@ targets in the next section. [async-iterator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator [async-iteration-protocols]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols [asynchronous iterators]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of -[operation]: https://deno.land/x/effection/mod.ts?s=Operation -[subscription]: https://deno.land/x/effection/mod.ts?s=Subscription -[stream]: https://deno.land/x/effection/mod.ts?s=Stream -[channel]: https://deno.land/x/effection/mod.ts?s=Channel -[signal]: https://deno.land/x/effection/mod.ts?s=Signal -[createSignal]: https://deno.land/x/effection/mod.ts?s=createSignal +[operation]: /api/v4/Operation +[subscription]: /api/v4/Subscription +[stream]: /api/v4/Stream +[channel]: /api/v4/Channel +[signal]: /api/v4/Signal +[createSignal]: /api/v4/createSignal [fp-ts.pipe]: https://gcanti.github.io/fp-ts/modules/function.ts.html#pipe [lodash.flow]: https://lodash.com/docs/4.17.15#flow [close-event]: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent [event-target]: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget -[on]: https://deno.land/x/effection/mod.ts?s=on +[on]: /api/v4/on diff --git a/docs/errors.mdx b/docs/errors.mdx index 4ef28234b..739ae490a 100644 --- a/docs/errors.mdx +++ b/docs/errors.mdx @@ -167,4 +167,4 @@ await main(function*() { Now, when our spawned task throws an error, control will pass to our catch handler. [Resource]: ./resources -[task]: https://jsr.io/@effection/effection/doc/~/Task +[task]: /api/v4/Task diff --git a/docs/events.mdx b/docs/events.mdx index e163b9b6f..eaf7e3f77 100644 --- a/docs/events.mdx +++ b/docs/events.mdx @@ -72,6 +72,6 @@ await main(function*() { [wscode]: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent [childprocess]: https://nodejs.org/api/child_process.html#child_process_class_childprocess -[once]: https://deno.land/x/effection/mod.ts?s=once -[on]: https://deno.land/x/effection/mod.ts?s=on +[once]: /api/v4/once +[on]: /api/v4/on [event-target]: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget diff --git a/docs/operations.mdx b/docs/operations.mdx index 7b15f7555..729dfe25c 100644 --- a/docs/operations.mdx +++ b/docs/operations.mdx @@ -252,6 +252,6 @@ the programmer free to not worry about it. [async function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function [promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise [spawn-suspend]: ./spawn#suspend -[run]: https://deno.land/x/effection/mod.ts?s=run -[suspend]: https://deno.land/x/effection/mod.ts?s=suspend -[ensure]: https://deno.land/x/effection/mod.ts?s=ensure +[run]: /api/v4/run +[suspend]: /api/v4/suspend +[ensure]: /api/v4/ensure diff --git a/docs/resources.mdx b/docs/resources.mdx index 30710b3c1..a5486635e 100644 --- a/docs/resources.mdx +++ b/docs/resources.mdx @@ -179,6 +179,6 @@ Resources allow us to create powerful, reusable abstractions which are also able to clean up after themselves. [tasks]: /docs/guides/tasks -[operation]: https://deno.land/x/effection/mod.ts?s=Operation -[resource]: https://deno.land/x/effection/mod.ts?s=resource -[ensure]: https://deno.land/x/effection/mod.ts?s=ensure +[operation]: /api/v4/Operation +[resource]: /api/v4/resource +[ensure]: /api/v4/ensure diff --git a/docs/scope.mdx b/docs/scope.mdx index 3d6a3f208..233bfe9f2 100644 --- a/docs/scope.mdx +++ b/docs/scope.mdx @@ -349,7 +349,7 @@ Whenever you use `createScope()`, it is important that you `await` the destruction operation when the scope is no longer needed since it will not be destroyed implicitly. -[spawn]: https://deno.land/x/effection/mod.ts?s=spawn -[use-abort-signal]: https://deno.land/x/effection/mod.ts?s=useAbortSignal -[scope]: https://deno.land/x/effection/mod.ts?s=Scope -[create-scope]: https://deno.land/x/effection/mod.ts?s=createScope +[spawn]: /api/v4/spawn +[use-abort-signal]: /api/v4/useAbortSignal +[scope]: /api/v4/Scope +[create-scope]: /api/v4/createScope diff --git a/docs/spawn.mdx b/docs/spawn.mdx index 101d0efd1..a52ea2eda 100644 --- a/docs/spawn.mdx +++ b/docs/spawn.mdx @@ -204,5 +204,5 @@ You can learn more about this in the [scope guide](./scope). [structured concurrency]: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ [express]: https://expressjs.org -[scope.run]: https://deno.land/x/effection/mod.ts?s=Scope#method_run_0 -[Operation]: https://deno.land/x/effection/mod.ts?s=Operation +[scope.run]: /api/v4/Scope +[Operation]: /api/v4/Operation diff --git a/docs/typescript.mdx b/docs/typescript.mdx index a2e7218d0..d4ffbe0c8 100644 --- a/docs/typescript.mdx +++ b/docs/typescript.mdx @@ -89,4 +89,4 @@ it and expect to receive a number. Now anybody using your operation won't have any doubt about what it is and how they can use it. -[operation]: https://deno.land/x/effection/mod.ts?s=Operation +[operation]: /api/v4/Operation From 45c61b6f1ca553e4c0d6c7e3f98c411fea2505b2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 10 Apr 2025 17:50:05 -0400 Subject: [PATCH 004/119] Added feedback --- lib/call.ts | 16 ---------------- lib/mod.ts | 1 + lib/until.ts | 21 +++++++++++++++++++++ test/call.test.ts | 6 +----- test/until.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 45 insertions(+), 21 deletions(-) create mode 100644 lib/until.ts create mode 100644 test/until.test.ts diff --git a/lib/call.ts b/lib/call.ts index 9a1713367..50cd79c76 100644 --- a/lib/call.ts +++ b/lib/call.ts @@ -101,22 +101,6 @@ export function call( }; } -/** - * It can be used to treat a promise as an operation. This function - * is a replacement to the v3 deprecated `call(promise)` function form. - * - * @example - * ```js - * let response = yield* until(fetch('https://google.com')); - * ``` - * @template {T} - * @param promise - * @returns {Operation} - */ -export function until(promise: PromiseLike): Operation { - return call(async () => await promise); -} - function isPromise( target: Operation | Promise | T, ): target is Promise { diff --git a/lib/mod.ts b/lib/mod.ts index 49d731784..4b0f0cf63 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -23,3 +23,4 @@ export * from "./main.ts"; export * from "./with-resolvers.ts"; export * from "./async.ts"; export * from "./scoped.ts"; +export * from "./until.ts"; diff --git a/lib/until.ts b/lib/until.ts new file mode 100644 index 000000000..096eab364 --- /dev/null +++ b/lib/until.ts @@ -0,0 +1,21 @@ +import { action } from "./action.ts"; +import type { Operation } from "./types.ts"; + +/** + * It can be used to treat a promise as an operation. This function + * is a replacement to the v3 deprecated `call(promise)` function form. + * + * @example + * ```js + * let response = yield* until(fetch('https://google.com')); + * ``` + * @template {T} + * @param promise + * @returns {Operation} + */ +export function until(promise: Promise): Operation { + return action((resolve, reject) => { + promise.then(resolve).catch(reject); + return () => {}; + }); +} diff --git a/test/call.test.ts b/test/call.test.ts index 42314f3c6..19229097a 100644 --- a/test/call.test.ts +++ b/test/call.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "./suite.ts"; -import { call, run, until } from "../mod.ts"; +import { call, run } from "../mod.ts"; describe("call", () => { it("evaluates an operation function", async () => { @@ -22,10 +22,6 @@ describe("call", () => { )).resolves.toEqual(42); }); - it("evaluates a promise directly with `until`", async () => { - await expect(run(() => until(Promise.resolve(42)))).resolves.toEqual(42); - }); - it("evaluates a no-arg async function", async () => { await expect(run(() => call(() => Promise.resolve(42)))).resolves.toEqual( 42, diff --git a/test/until.test.ts b/test/until.test.ts new file mode 100644 index 000000000..a15ca61a1 --- /dev/null +++ b/test/until.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "./suite.ts"; + +import { run, until } from "../mod.ts"; + +describe("until", () => { + it("resolves on success", async () => { + expect.assertions(1); + await run(function* () { + expect(yield* until(Promise.resolve(42))).toEqual(42); + }); + }); + it("throws on error", async () => { + expect.assertions(1); + await run(function* () { + try { + yield* until(Promise.reject("error")); + } catch (error) { + expect(error).toBe("error"); + } + }); + }); +}); From f353592ed286c7def63b41e57f42309e06b0eb04 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Apr 2025 15:13:21 -0400 Subject: [PATCH 005/119] Update lib/until.ts Co-authored-by: Charles Lowell --- lib/until.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/until.ts b/lib/until.ts index 096eab364..bdeaf2a3e 100644 --- a/lib/until.ts +++ b/lib/until.ts @@ -2,8 +2,7 @@ import { action } from "./action.ts"; import type { Operation } from "./types.ts"; /** - * It can be used to treat a promise as an operation. This function - * is a replacement to the v3 deprecated `call(promise)` function form. + * Treat a promise as an {@link Operation} * * @example * ```js From 8d8f96fc74e12c13f96975538a31998713f16bd1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Apr 2025 15:13:33 -0400 Subject: [PATCH 006/119] Update lib/until.ts Co-authored-by: Charles Lowell --- lib/until.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/until.ts b/lib/until.ts index bdeaf2a3e..6312bb8c1 100644 --- a/lib/until.ts +++ b/lib/until.ts @@ -10,7 +10,7 @@ import type { Operation } from "./types.ts"; * ``` * @template {T} * @param promise - * @returns {Operation} + * @returns {Operation} that succeeds or fails depending on the outcome of `promise` */ export function until(promise: Promise): Operation { return action((resolve, reject) => { From ace1e2c5cd0e940b7b23a75a55af1f0eac5275a8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Apr 2025 15:47:16 -0400 Subject: [PATCH 007/119] docs: fix additional spelling and formatting issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed number formatting in thinking-in-effection.mdx (removed commas in timeout values) - Removed duplicate section in async-rosetta-stone.mdx - Fixed URL format in Changelog.md - Fixed spacing typos in Changelog.md ("w ith" β†’ "with", "iterat or" β†’ "iterator") πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Changelog.md | 6 +++--- docs/async-rosetta-stone.mdx | 33 --------------------------------- docs/thinking-in-effection.mdx | 4 ++-- 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/Changelog.md b/Changelog.md index 90174b6af..3b547a76a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -31,7 +31,7 @@ also async functions and vanilla JavaScript functions https://github.com/thefrontside/effection/pull/832 - Task.halt() now succeeds whenever the shutdown succeeds, regardless of whether - the task itself failed https://github.com/thefrontside/pull/effection/837 + the task itself failed https://github.com/thefrontside/effection/pull/837 - ✨allow custom `Queue` impl whenever creating a `Signal` https://github.com/thefrontside/effection/pull/826 - πŸ“„Represent `each` as a function, not a variable in the API docs @@ -76,9 +76,9 @@ https://github.com/thefrontside/effection/pull/729 - add `main()` method for setting up Effection to work properly in working in deno, browser, and node -- add `Context.set()` and `Context.get()` operations to make working w ith +- add `Context.set()` and `Context.get()` operations to make working with Context convenient -- convert `Subscription` interface from a bare operation to an "iterat or" style +- convert `Subscription` interface from a bare operation to an "iterator" style interface. Succintly: `yield* subscription` -> `yield* subscription.next()`. For details https://github.com/thefrontside/effection/issues/693 - add `on()` and `once()` operations for events and subscriptions to values that diff --git a/docs/async-rosetta-stone.mdx b/docs/async-rosetta-stone.mdx index 62edce077..3784f2325 100644 --- a/docs/async-rosetta-stone.mdx +++ b/docs/async-rosetta-stone.mdx @@ -201,39 +201,6 @@ function* main() { }; ``` -## `Promise.withResolvers()` \<=> `withResolvers()` - -Both `Promise` and `Operation` can be constructed ahead of time without needing to begin the process that will resolve it. To do this with -a `Promise`, use the `Promise.withResolvers()` function: - -```ts -async function main() { - let { promise, resolve } = Promise.withResolvers(); - - setTimeout(resolve, 1000); - - await promise; - - console.log("done!") -} -``` - -In effection: - -```ts -import { withResolvers } from "effection"; - -function* main() { - let { operation, resolve } = withResolvers(); - - setTimeout(resolve, 1000); - - yield* operation; - - console.log("done!"); -}; -``` - ## `for await` \<=> `for yield* each` Loop over an AsyncIterable with `for await`: diff --git a/docs/thinking-in-effection.mdx b/docs/thinking-in-effection.mdx index 6ab6b1c67..cea0016a2 100644 --- a/docs/thinking-in-effection.mdx +++ b/docs/thinking-in-effection.mdx @@ -59,7 +59,7 @@ However, the same guarantee is not provided for async functions. ```js {5} showLineNumbers async function main() { try { - await new Promise((resolve) => setTimeout(resolve, 100,000)); + await new Promise((resolve) => setTimeout(resolve, 100000)); } finally { // code here is NOT GUARANTEED to run } @@ -82,7 +82,7 @@ import { main, action } from "effection"; await main(function*() { try { - yield* action(function*(resolve) { setTimeout(resolve, 100,000) }); + yield* action(function*(resolve) { setTimeout(resolve, 100000) }); } finally { // code here is GUARANTEED to run } From 472323c68aa624a698418a8b2f6f3dc9a6937dbd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Apr 2025 20:45:40 -0400 Subject: [PATCH 008/119] docs: update changelog with v4.0.0-alpha entries organized by version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added all PRs merged between alpha.0 and alpha.8 tags - Organized entries by their respective alpha versions - Added initial placeholder for alpha.0 πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Changelog.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/Changelog.md b/Changelog.md index 3b547a76a..fa5d32085 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,61 @@ # Changelog +## 4.0.0-alpha.8 + +- Add `until()` operation for turning promises into operations https://github.com/thefrontside/effection/pull/990 +- Add Effect.js benchmarks for performance comparison https://github.com/thefrontside/effection/pull/979 + +## 4.0.0-alpha.7 + +- Fix type definition for call operation https://github.com/thefrontside/effection/pull/973 +- Fix missing tag issue https://github.com/thefrontside/effection/pull/970 +- Minor documentation improvements https://github.com/thefrontside/effection/pull/971 + +## 4.0.0-alpha.6 + +- Remove unnecessary www from deploy preview URLs https://github.com/thefrontside/effection/pull/969 +- Add promise helpers https://github.com/thefrontside/effection/pull/968 + +## 4.0.0-alpha.5 + +- Test against Node 16, 18, 20 versions https://github.com/thefrontside/effection/pull/966 +- Add documentation for scope https://github.com/thefrontside/effection/pull/961 +- Add documentation for errors https://github.com/thefrontside/effection/pull/960 +- Add documentation for call operation https://github.com/thefrontside/effection/pull/954 +- Add documentation for suspend operation https://github.com/thefrontside/effection/pull/957 +- Add documentation for constant operation https://github.com/thefrontside/effection/pull/955 +- Add withResolvers test https://github.com/thefrontside/effection/pull/953 +- Fix typo in documentation https://github.com/thefrontside/effection/pull/952 +- Add documentation for action operation https://github.com/thefrontside/effection/pull/951 +- Add contributors section to README https://github.com/thefrontside/effection/pull/948 +- Add JSR publishing capability https://github.com/thefrontside/effection/pull/947 +- Add withResolvers documentation https://github.com/thefrontside/effection/pull/940 +- Remove v2 documentation https://github.com/thefrontside/effection/pull/938 + +## 4.0.0-alpha.4 + +- Add dynamic import for Node.js main https://github.com/thefrontside/effection/pull/936 +- Add scoped operation https://github.com/thefrontside/effection/pull/933 +- Export Result interface from API https://github.com/thefrontside/effection/pull/920 +- Add benchmark suite https://github.com/thefrontside/effection/pull/919 + +## 4.0.0-alpha.3 + +- Add do-effect helper https://github.com/thefrontside/effection/pull/918 +- Use Deno 2.0 https://github.com/thefrontside/effection/pull/917 + +## 4.0.0-alpha.2 + +- Make Task implement Operation https://github.com/thefrontside/effection/pull/915 + +## 4.0.0-alpha.1 + +- Export async helpers in main API https://github.com/thefrontside/effection/pull/913 + +## 4.0.0-alpha.0 + +- Initial v4 release with delimited continuations + ## 3.0.3 - this is just a placeholder release in order to workaround an issue. It is 100% From 49d67e4470692ab0a765e1df5e0436068c62cfc6 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Apr 2025 20:58:28 -0400 Subject: [PATCH 009/119] Fixed formatting --- Changelog.md | 54 ++++++++++++++++++++++++++++++++++------------------ deno.json | 40 ++++++++------------------------------ 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/Changelog.md b/Changelog.md index fa5d32085..0c8d23321 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,41 +2,57 @@ ## 4.0.0-alpha.8 -- Add `until()` operation for turning promises into operations https://github.com/thefrontside/effection/pull/990 -- Add Effect.js benchmarks for performance comparison https://github.com/thefrontside/effection/pull/979 +- Add `until()` operation for turning promises into operations + https://github.com/thefrontside/effection/pull/990 +- Add Effect.js benchmarks for performance comparison + https://github.com/thefrontside/effection/pull/979 ## 4.0.0-alpha.7 -- Fix type definition for call operation https://github.com/thefrontside/effection/pull/973 +- Fix type definition for call operation + https://github.com/thefrontside/effection/pull/973 - Fix missing tag issue https://github.com/thefrontside/effection/pull/970 -- Minor documentation improvements https://github.com/thefrontside/effection/pull/971 +- Minor documentation improvements + https://github.com/thefrontside/effection/pull/971 ## 4.0.0-alpha.6 -- Remove unnecessary www from deploy preview URLs https://github.com/thefrontside/effection/pull/969 +- Remove unnecessary www from deploy preview URLs + https://github.com/thefrontside/effection/pull/969 - Add promise helpers https://github.com/thefrontside/effection/pull/968 ## 4.0.0-alpha.5 -- Test against Node 16, 18, 20 versions https://github.com/thefrontside/effection/pull/966 +- Test against Node 16, 18, 20 versions + https://github.com/thefrontside/effection/pull/966 - Add documentation for scope https://github.com/thefrontside/effection/pull/961 -- Add documentation for errors https://github.com/thefrontside/effection/pull/960 -- Add documentation for call operation https://github.com/thefrontside/effection/pull/954 -- Add documentation for suspend operation https://github.com/thefrontside/effection/pull/957 -- Add documentation for constant operation https://github.com/thefrontside/effection/pull/955 +- Add documentation for errors + https://github.com/thefrontside/effection/pull/960 +- Add documentation for call operation + https://github.com/thefrontside/effection/pull/954 +- Add documentation for suspend operation + https://github.com/thefrontside/effection/pull/957 +- Add documentation for constant operation + https://github.com/thefrontside/effection/pull/955 - Add withResolvers test https://github.com/thefrontside/effection/pull/953 - Fix typo in documentation https://github.com/thefrontside/effection/pull/952 -- Add documentation for action operation https://github.com/thefrontside/effection/pull/951 -- Add contributors section to README https://github.com/thefrontside/effection/pull/948 -- Add JSR publishing capability https://github.com/thefrontside/effection/pull/947 -- Add withResolvers documentation https://github.com/thefrontside/effection/pull/940 +- Add documentation for action operation + https://github.com/thefrontside/effection/pull/951 +- Add contributors section to README + https://github.com/thefrontside/effection/pull/948 +- Add JSR publishing capability + https://github.com/thefrontside/effection/pull/947 +- Add withResolvers documentation + https://github.com/thefrontside/effection/pull/940 - Remove v2 documentation https://github.com/thefrontside/effection/pull/938 ## 4.0.0-alpha.4 -- Add dynamic import for Node.js main https://github.com/thefrontside/effection/pull/936 +- Add dynamic import for Node.js main + https://github.com/thefrontside/effection/pull/936 - Add scoped operation https://github.com/thefrontside/effection/pull/933 -- Export Result interface from API https://github.com/thefrontside/effection/pull/920 +- Export Result interface from API + https://github.com/thefrontside/effection/pull/920 - Add benchmark suite https://github.com/thefrontside/effection/pull/919 ## 4.0.0-alpha.3 @@ -46,11 +62,13 @@ ## 4.0.0-alpha.2 -- Make Task implement Operation https://github.com/thefrontside/effection/pull/915 +- Make Task implement Operation + https://github.com/thefrontside/effection/pull/915 ## 4.0.0-alpha.1 -- Export async helpers in main API https://github.com/thefrontside/effection/pull/913 +- Export async helpers in main API + https://github.com/thefrontside/effection/pull/913 ## 4.0.0-alpha.0 diff --git a/deno.json b/deno.json index 7b570a507..f0f7a5801 100644 --- a/deno.json +++ b/deno.json @@ -2,9 +2,7 @@ "name": "@effection/effection", "exports": "./mod.ts", "license": "ISC", - "publish": { - "include": ["lib", "mod.ts", "README.md"] - }, + "publish": { "include": ["lib", "mod.ts", "README.md"] }, "lock": false, "tasks": { "test": "deno test --allow-run=deno --allow-read --allow-env", @@ -14,39 +12,17 @@ "build:jsr": "deno run -A tasks/build-jsr.ts" }, "lint": { - "rules": { - "exclude": ["prefer-const", "require-yield"] - }, - "exclude": [ - "build", - "www" - ] - }, - "fmt": { - "exclude": [ - "build", - "www", - "CODE_OF_CONDUCT.md", - "README.md" - ] - }, - "test": { - "exclude": [ - "build" - ] - }, - "compilerOptions": { - "lib": [ - "deno.ns", - "esnext", - "dom", - "dom.iterable" - ] + "rules": { "exclude": ["prefer-const", "require-yield"] }, + "exclude": ["build", "www"] }, + "fmt": { "exclude": ["build", "www", "CODE_OF_CONDUCT.md", "README.md"] }, + "test": { "exclude": ["build"] }, + "compilerOptions": { "lib": ["deno.ns", "esnext", "dom", "dom.iterable"] }, "imports": { "@std/testing": "jsr:@std/testing@1", "ts-expect": "npm:ts-expect@1.3.0", "@std/expect": "jsr:@std/expect@1", "tinyexec": "npm:tinyexec@0.3.2" - } + }, + "version": "4.0.0-alpha.8" } From 8cf4d08902317d9c04c9c89e53b2c81ee2f3cd0d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 1 May 2025 07:51:10 -0500 Subject: [PATCH 010/119] Add binary minheap priority queue for effect resolution --- lib/priority-queue.ts | 91 +++++++++++++++++++++++++++++++++++++ test/priority-queue.test.ts | 33 ++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 lib/priority-queue.ts create mode 100644 test/priority-queue.test.ts diff --git a/lib/priority-queue.ts b/lib/priority-queue.ts new file mode 100644 index 000000000..6213643e8 --- /dev/null +++ b/lib/priority-queue.ts @@ -0,0 +1,91 @@ +export class PriorityQueue { + public tiers: Tier[] = []; + public heap: Tier[] = []; + + push(priority: number, item: T) { + let tier = this.tiers[priority]; + if (tier) { + tier.items.unshift(item); + } else { + let tier = new Tier(priority, [item]); + this.tiers[priority] = tier; + let current = this.heap.length; + this.heap.push(tier); + while (current > 0) { + let p = parentOf(current); + if (priority < this.heap[p].priority) { + this.heap[current] = this.heap[p]; + this.heap[p] = tier; + } + current = p; + } + } + } + + pop(): T | undefined { + let top = this.heap[0]; + if (!top) { + return; + } + let value = top.items.pop()!; + if (top.items.length === 0) { + delete this.tiers[top.priority]; + let tier = this.heap.pop()!; + if (this.heap.length > 0) { + let current = 0; + this.heap[0] = tier; + while (current < this.heap.length - 1) { + let left_i = leftOf(current); + let right_i = rightOf(current); + let left = this.heap[left_i]; + let right = this.heap[right_i]; + + if (left) { + if (right) { + if ( + (tier.priority > left.priority) || + (tier.priority > right.priority) + ) { + if (left.priority < right.priority) { + this.heap[current] = left; + this.heap[left_i] = tier; + current = left_i; + } else { + this.heap[current] = right; + this.heap[right_i] = tier; + current = right_i; + } + } else { + break; + } + } else if (left.priority < tier.priority) { + this.heap[current] = left; + this.heap[left_i] = tier; + current = left_i; + } else { + break; + } + } else { + break; + } + } + } + } + return value; + } +} + +class Tier { + constructor(public priority: number, public items: T[]) {} +} + +function parentOf(index: number): number { + return Math.floor((index - 1) / 2); +} +function leftOf(index: number) { + return index * 2 + 1; +} + +function rightOf(index: number) { + return index * 2 + 2; +} diff --git a/test/priority-queue.test.ts b/test/priority-queue.test.ts new file mode 100644 index 000000000..0de13a5fa --- /dev/null +++ b/test/priority-queue.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "node:test"; +import { PriorityQueue } from "../lib/priority-queue.ts"; +import { expect } from "./suite.ts"; + + +describe("priority queue", () => { + it("gives priority to lower numbers", () => { + let q = new PriorityQueue(); + q.push(3, "!"); + q.push(1, "hello"); + q.push(2, "world"); + + expect(`${q.pop()} ${q.pop()}${q.pop()}`).toEqual("hello world!"); + + }); + it("within a priority cohort, it is FIFO", () => { + let q = new PriorityQueue(); + q.push(0, "hello"); + q.push(0, "world"); + q.push(0, "!"); + + q.push(1, "fire"); + q.push(6, "disco"); + q.push(6, "ballz"); + + expect(`${q.pop()} ${q.pop()}${q.pop()}`).toEqual("hello world!"); + }); + + it("produces undefined when there is nothing on the queeu", () => { + let q = new PriorityQueue(); + expect(q.pop()).toBeUndefined(); + }) +}) From 056cd0349db3584c0191d27bbca3c0f46af398c3 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 3 May 2025 12:08:54 -0500 Subject: [PATCH 011/119] Add coroutine version bump on return When you call coroutine return, any instructions that are currently enqueued for it are discarded. --- lib/coroutine.ts | 4 +++ lib/priority-queue.ts | 12 +++---- lib/reducer.ts | 62 +++++++++++++++++++++++++++++-------- lib/types.ts | 1 + test/priority-queue.test.ts | 10 +++--- 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/lib/coroutine.ts b/lib/coroutine.ts index 6e5d3852d..160209da0 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -30,6 +30,7 @@ export function createCoroutine( let iterator: Coroutine["data"]["iterator"] | undefined = undefined; let routine = { + version: 0, scope, data: { get iterator() { @@ -52,12 +53,14 @@ export function createCoroutine( exit.ok ? result : exit, send as Subscriber, "next", + routine.version, ]); }); return () => subscriber && subscribers.delete(subscriber); }, return(result, subscriber?: Subscriber) { + routine.version++; if (subscriber) { subscribers.add(subscriber as Subscriber); } @@ -69,6 +72,7 @@ export function createCoroutine( exit.ok ? result : exit, send as Subscriber, "return", + routine.version, ]); }); diff --git a/lib/priority-queue.ts b/lib/priority-queue.ts index 6213643e8..cdd5545d2 100644 --- a/lib/priority-queue.ts +++ b/lib/priority-queue.ts @@ -56,18 +56,18 @@ export class PriorityQueue { current = right_i; } } else { - break; - } + break; + } } else if (left.priority < tier.priority) { this.heap[current] = left; this.heap[left_i] = tier; current = left_i; } else { - break; - } + break; + } } else { - break; - } + break; + } } } } diff --git a/lib/reducer.ts b/lib/reducer.ts index 4753badee..63a36e0a1 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -1,10 +1,11 @@ import { createContext } from "./context.ts"; +import { PriorityQueue } from "./priority-queue.ts"; import { Err, Ok, type Result } from "./result.ts"; import type { Coroutine, Subscriber } from "./types.ts"; export class Reducer { reducing = false; - readonly queue = createPriorityQueue(); + readonly queue = createPriorityQueue2(); reduce = ( thunk: Thunk, @@ -77,26 +78,61 @@ type Thunk = [ Result, Subscriber, "return" | "next", + number, ]; -// This is a pretty hokey priority queue that uses an array for storage -// so enqueue is O(n). However, `n` is generally small. revisit. -function createPriorityQueue() { - let thunks: Thunk[] = []; +// // This is a pretty hokey priority queue that uses an array for storage +// // so enqueue is O(n). However, `n` is generally small. revisit. +// function createPriorityQueue() { +// let thunks: Thunk[] = []; + +// return { +// enqueue(thunk: Thunk): void { +// let [priority] = thunk; +// let index = thunks.findIndex(([p]) => p >= priority); +// if (index === -1) { +// thunks.push(thunk); +// } else { +// thunks.splice(index, 0, thunk); +// } +// }, + +// dequeue(): Thunk | undefined { +// return thunks.shift(); +// }, +// }; +// } + +function createPriorityQueue2() { + let q = new PriorityQueue(); return { enqueue(thunk: Thunk): void { let [priority] = thunk; - let index = thunks.findIndex(([p]) => p >= priority); - if (index === -1) { - thunks.push(thunk); - } else { - thunks.splice(index, 0, thunk); - } + q.push(priority, thunk); }, - dequeue(): Thunk | undefined { - return thunks.shift(); + while (true) { + let top = q.pop(); + if (!top) { + return undefined; + } else if (top[5] < top[1].version) { + continue; + } else { + return top; + } + } }, }; } + +function qdir(cxt: string, q: PriorityQueue): void { + console.log(cxt); + console.dir( + q.tiers.filter((t) => !!t).map((t) => ({ + priority: t.priority, + items: t.items.map((i) => ({ c: i[1], r: i[2], t: i[4], v: i[5] })), + })), + { depth: 10 }, + ); +} diff --git a/lib/types.ts b/lib/types.ts index 70d01edf3..97843050e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -355,6 +355,7 @@ export interface Effect { * @ignore */ export interface Coroutine { + version: number; scope: Scope; data: { discard(resolve: Resolve>): void; diff --git a/test/priority-queue.test.ts b/test/priority-queue.test.ts index 0de13a5fa..9c3e49277 100644 --- a/test/priority-queue.test.ts +++ b/test/priority-queue.test.ts @@ -2,16 +2,14 @@ import { describe, it } from "node:test"; import { PriorityQueue } from "../lib/priority-queue.ts"; import { expect } from "./suite.ts"; - describe("priority queue", () => { it("gives priority to lower numbers", () => { let q = new PriorityQueue(); q.push(3, "!"); - q.push(1, "hello"); q.push(2, "world"); - - expect(`${q.pop()} ${q.pop()}${q.pop()}`).toEqual("hello world!"); + q.push(1, "hello"); + expect(`${q.pop()} ${q.pop()}${q.pop()}`).toEqual("hello world!"); }); it("within a priority cohort, it is FIFO", () => { let q = new PriorityQueue(); @@ -29,5 +27,5 @@ describe("priority queue", () => { it("produces undefined when there is nothing on the queeu", () => { let q = new PriorityQueue(); expect(q.pop()).toBeUndefined(); - }) -}) + }); +}); From ece899687f1732d310976ae5a75ea47390cf8260 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 6 May 2025 12:47:44 -0500 Subject: [PATCH 012/119] fix broken channel test based on new evaluation order --- test/channel.test.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/test/channel.test.ts b/test/channel.test.ts index f85f39cc2..53537f94b 100644 --- a/test/channel.test.ts +++ b/test/channel.test.ts @@ -7,7 +7,13 @@ import { } from "./suite.ts"; import type { Channel, Operation } from "../mod.ts"; -import { createChannel, createScope, sleep, spawn } from "../mod.ts"; +import { + createChannel, + createScope, + sleep, + spawn, + withResolvers, +} from "../mod.ts"; let [scope, close] = createScope(); @@ -20,21 +26,31 @@ describe("Channel", () => { it("does not use the same event twice when serially subscribed to a channel", function* () { let input = createChannel(); + let proceed = withResolvers(); + let actual: string[] = []; function* channel() { - yield* sleep(10); yield* input.send("one"); + + yield* proceed.operation; + yield* input.send("two"); } function* root() { + // subscribe once + let subscription = yield* input; + yield* spawn(channel); - let subscription = yield* input; let result = yield* subscription.next(); actual.push(result.value as string); subscription = yield* input; + + // it's ok to emit the second output because we're listening for it + proceed.resolve(); + result = yield* subscription.next(); actual.push(result.value as string); } From 93805a307f874cd30c7c84990cba5ff17ec4158f Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 6 May 2025 12:50:32 -0500 Subject: [PATCH 013/119] Undo workaround for lack of FIFO in instruction execution --- lib/race.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/race.ts b/lib/race.ts index df6be05ca..0c75f54e9 100644 --- a/lib/race.ts +++ b/lib/race.ts @@ -41,7 +41,7 @@ export function* race>( // encapsulate the race in a hermetic scope. let result = yield* trap(() => encapsulate(function* () { - for (let operation of operations.slice().reverse()) { + for (let operation of operations.slice()) { tasks.push( yield* spawn(function* () { // let contestant = yield* useScope(); From 7737f1adee3fe0f3a1b8c6dd20eead5a2233be10 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 6 May 2025 13:17:06 -0500 Subject: [PATCH 014/119] simplify the each test sequence to create less resources --- test/each.test.ts | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/test/each.test.ts b/test/each.test.ts index 008730729..2e8d92076 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "./suite.ts"; import { - createQueue, each, + type Operation, resource, run, spawn, @@ -30,20 +30,16 @@ describe("each", () => { await run(function* () { let actual = [] as string[]; let outer = sequence("one", "two"); - let inner = sequence("three", "four", "five"); + let inner = sequence("three"); - let consumer = yield* spawn(function* () { - for (let value of yield* each(outer)) { + for (let value of yield* each(outer)) { + actual.push(value); + for (let value of yield* each(inner)) { actual.push(value); - for (let value of yield* each(inner)) { - actual.push(value); - yield* each.next(); - } yield* each.next(); } - }); - - yield* consumer; + yield* each.next(); + } expect(actual).toEqual([ "one", @@ -113,12 +109,19 @@ describe("each", () => { }); function sequence(...values: string[]): Stream { - return resource(function* (provide) { - let q = createQueue(); - for (let value of values) { - q.add(value); + return { + *[Symbol.iterator]() { + let items = values.slice(); + return { + *next(): Operation> { + let value = items.shift(); + if (typeof value !== "undefined") { + return { done: false, value }; + } else { + return { done: true, value: undefined } + } + } + } } - q.close(); - yield* provide(q); - }); + } } From 03372d285056b63400d9485befbcec2fff3a70ed Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Apr 2025 15:47:16 -0400 Subject: [PATCH 015/119] docs: fix additional spelling and formatting issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed number formatting in thinking-in-effection.mdx (removed commas in timeout values) - Removed duplicate section in async-rosetta-stone.mdx - Fixed URL format in Changelog.md - Fixed spacing typos in Changelog.md ("w ith" β†’ "with", "iterat or" β†’ "iterator") πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Changelog.md | 6 +++--- docs/async-rosetta-stone.mdx | 33 --------------------------------- docs/thinking-in-effection.mdx | 4 ++-- 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/Changelog.md b/Changelog.md index 90174b6af..3b547a76a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -31,7 +31,7 @@ also async functions and vanilla JavaScript functions https://github.com/thefrontside/effection/pull/832 - Task.halt() now succeeds whenever the shutdown succeeds, regardless of whether - the task itself failed https://github.com/thefrontside/pull/effection/837 + the task itself failed https://github.com/thefrontside/effection/pull/837 - ✨allow custom `Queue` impl whenever creating a `Signal` https://github.com/thefrontside/effection/pull/826 - πŸ“„Represent `each` as a function, not a variable in the API docs @@ -76,9 +76,9 @@ https://github.com/thefrontside/effection/pull/729 - add `main()` method for setting up Effection to work properly in working in deno, browser, and node -- add `Context.set()` and `Context.get()` operations to make working w ith +- add `Context.set()` and `Context.get()` operations to make working with Context convenient -- convert `Subscription` interface from a bare operation to an "iterat or" style +- convert `Subscription` interface from a bare operation to an "iterator" style interface. Succintly: `yield* subscription` -> `yield* subscription.next()`. For details https://github.com/thefrontside/effection/issues/693 - add `on()` and `once()` operations for events and subscriptions to values that diff --git a/docs/async-rosetta-stone.mdx b/docs/async-rosetta-stone.mdx index 62edce077..3784f2325 100644 --- a/docs/async-rosetta-stone.mdx +++ b/docs/async-rosetta-stone.mdx @@ -201,39 +201,6 @@ function* main() { }; ``` -## `Promise.withResolvers()` \<=> `withResolvers()` - -Both `Promise` and `Operation` can be constructed ahead of time without needing to begin the process that will resolve it. To do this with -a `Promise`, use the `Promise.withResolvers()` function: - -```ts -async function main() { - let { promise, resolve } = Promise.withResolvers(); - - setTimeout(resolve, 1000); - - await promise; - - console.log("done!") -} -``` - -In effection: - -```ts -import { withResolvers } from "effection"; - -function* main() { - let { operation, resolve } = withResolvers(); - - setTimeout(resolve, 1000); - - yield* operation; - - console.log("done!"); -}; -``` - ## `for await` \<=> `for yield* each` Loop over an AsyncIterable with `for await`: diff --git a/docs/thinking-in-effection.mdx b/docs/thinking-in-effection.mdx index 6ab6b1c67..cea0016a2 100644 --- a/docs/thinking-in-effection.mdx +++ b/docs/thinking-in-effection.mdx @@ -59,7 +59,7 @@ However, the same guarantee is not provided for async functions. ```js {5} showLineNumbers async function main() { try { - await new Promise((resolve) => setTimeout(resolve, 100,000)); + await new Promise((resolve) => setTimeout(resolve, 100000)); } finally { // code here is NOT GUARANTEED to run } @@ -82,7 +82,7 @@ import { main, action } from "effection"; await main(function*() { try { - yield* action(function*(resolve) { setTimeout(resolve, 100,000) }); + yield* action(function*(resolve) { setTimeout(resolve, 100000) }); } finally { // code here is GUARANTEED to run } From a40fa9738b4cee25305234cd5e01643bef7b469b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 8 May 2025 18:12:19 -0500 Subject: [PATCH 016/119] =?UTF-8?q?=F0=9F=94=A5Remove=20obsolete=20$await?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that we have our very own `until()` function that can convert any promise into an operation, we don't need the the `$await()` helper that the tests were using that does the exact same thing. Kill it with fire! --- test/run.test.ts | 32 ++++++++++++++++++++------------ test/spawn.test.ts | 8 ++++---- test/suite.ts | 4 ---- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/test/run.test.ts b/test/run.test.ts index 49215c3b9..7ea683f96 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -1,6 +1,14 @@ // deno-lint-ignore-file no-unsafe-finally -import { $await, blowUp, createNumber, describe, expect, it } from "./suite.ts"; -import { action, run, sleep, spawn, suspend, type Task } from "../mod.ts"; +import { blowUp, createNumber, describe, expect, it } from "./suite.ts"; +import { + action, + run, + sleep, + spawn, + suspend, + type Task, + until, +} from "../mod.ts"; describe("run()", () => { it("can run an operation", async () => { @@ -11,8 +19,8 @@ describe("run()", () => { it("can compose multiple promises via generator", async () => { let result = await run(function* () { - let one = yield* $await(Promise.resolve(12)); - let two = yield* $await(Promise.resolve(55)); + let one = yield* until(Promise.resolve(12)); + let two = yield* until(Promise.resolve(55)); return one + two; }); expect(result).toEqual(67); @@ -49,16 +57,16 @@ describe("run()", () => { it("can recover from errors in promise", async () => { let error = new Error("boom"); let task = run(function* () { - let one = yield* $await(Promise.resolve(12)); + let one = yield* until(Promise.resolve(12)); let two; try { - yield* $await(Promise.reject(error)); + yield* until(Promise.reject(error)); two = 9; } catch (_) { // swallow error and yield in catch block - two = yield* $await(Promise.resolve(8)); + two = yield* until(Promise.resolve(8)); } - let three = yield* $await(Promise.resolve(55)); + let three = yield* until(Promise.resolve(55)); return one + two + three; }); await expect(task).resolves.toEqual(75); @@ -66,16 +74,16 @@ describe("run()", () => { it("can recover from errors in operation", async () => { let task = run(function* () { - let one = yield* $await(Promise.resolve(12)); + let one = yield* until(Promise.resolve(12)); let two; try { yield* blowUp(); two = 9; } catch (_e) { // swallow error and yield in catch block - two = yield* $await(Promise.resolve(8)); + two = yield* until(Promise.resolve(8)); } - let three = yield* $await(Promise.resolve(55)); + let three = yield* until(Promise.resolve(55)); return one + two + three; }); await expect(task).resolves.toEqual(75); @@ -308,7 +316,7 @@ describe("run()", () => { it("propagates errors from promises", async () => { try { await run(function* () { - yield* $await(Promise.reject(new Error("boom"))); + yield* until(Promise.reject(new Error("boom"))); }); throw new Error("expected error to propagate"); } catch (error) { diff --git a/test/spawn.test.ts b/test/spawn.test.ts index dea1c34c1..931cab6e4 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -1,13 +1,13 @@ // deno-lint-ignore-file no-unsafe-finally -import { $await, describe, expect, it } from "./suite.ts"; -import { run, sleep, spawn, suspend } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; +import { run, sleep, spawn, suspend, until } from "../mod.ts"; describe("spawn", () => { it("can spawn a new child task", async () => { let root = run(function* root() { let child = yield* spawn(function* child() { - let one = yield* $await(Promise.resolve(12)); - let two = yield* $await(Promise.resolve(55)); + let one = yield* until(Promise.resolve(12)); + let two = yield* until(Promise.resolve(55)); return one + two; }); return yield* child; diff --git a/test/suite.ts b/test/suite.ts index ef1d87a43..94d698fc8 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -6,10 +6,6 @@ import { type KillSignal, type Options, type Output, x as $x } from "tinyexec"; import type { Operation, Stream } from "../lib/types.ts"; import { call, resource, sleep, spawn, stream } from "../mod.ts"; -export function $await(promise: Promise): Operation { - return call(() => promise); -} - export function* createNumber(value: number): Operation { yield* sleep(1); return value; From 94ca32a72aac0662c5917931dc9080bd222e6e59 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 6 May 2025 23:19:05 -0500 Subject: [PATCH 017/119] enable preview packages --- .github/workflows/preview.yml | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/preview.yml diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 000000000..bc55a81ef --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,37 @@ +name: preview + +on: [pull_request] + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Get Version + id: vars + # this will pull the last tag off the current branch + run: echo ::set-output name=version::$(git describe --abbrev=0 --tags | sed 's/^effection-v//')-pr+$(git rev-parse HEAD) + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: https://registry.npmjs.com + + - name: Build NPM + run: deno task build:npm ${{steps.vars.outputs.version}} + + - name: Publish Preview Versions + run: npx pkg-pr-new publish './build/npm' From 100470bfe711e33f3fe02d82dad35cebb74c0a16 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 13 May 2025 11:27:39 -0500 Subject: [PATCH 018/119] get non-spawn run tests working --- lib/box.ts | 10 + lib/coroutine.ts | 14 +- lib/future.ts | 36 +++ lib/reducer.ts | 6 +- lib/task.ts | 572 +++++++++++++++++++++++++++++------------------ lib/types.ts | 2 +- test/run.test.ts | 131 +++++------ 7 files changed, 474 insertions(+), 297 deletions(-) create mode 100644 lib/box.ts create mode 100644 lib/future.ts diff --git a/lib/box.ts b/lib/box.ts new file mode 100644 index 000000000..ac14f01de --- /dev/null +++ b/lib/box.ts @@ -0,0 +1,10 @@ +import { Err, Ok, type Result } from "./result.ts"; +import type { Operation } from "./types.ts"; + +export function* box(op: () => Operation): Operation> { + try { + return Ok(yield* op()); + } catch (error) { + return Err(error as Error); + } +} diff --git a/lib/coroutine.ts b/lib/coroutine.ts index 6e5d3852d..a205dad34 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -38,18 +38,18 @@ export function createCoroutine( } return iterator; }, - discard: (resolve) => resolve(Ok()), + exit: (resolve) => resolve(Ok()), }, next(result, subscriber) { if (subscriber) { subscribers.add(subscriber); } - routine.data.discard((exit) => { - routine.data.discard = (resolve) => resolve(Ok()); + routine.data.exit((exitResult) => { + routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ scope.expect(Generation), routine, - exit.ok ? result : exit, + exitResult.ok ? result : exitResult, send as Subscriber, "next", ]); @@ -61,12 +61,12 @@ export function createCoroutine( if (subscriber) { subscribers.add(subscriber as Subscriber); } - routine.data.discard((exit) => { - routine.data.discard = (resolve) => resolve(Ok()); + routine.data.exit((exitResult) => { + routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ scope.expect(Generation), routine, - exit.ok ? result : exit, + exitResult.ok ? result : exitResult, send as Subscriber, "return", ]); diff --git a/lib/future.ts b/lib/future.ts new file mode 100644 index 000000000..7e465f7ec --- /dev/null +++ b/lib/future.ts @@ -0,0 +1,36 @@ +import { lazyPromiseWithResolvers } from "./lazy-promise.ts"; +import type { Future } from "./types.ts"; +import { withResolvers } from "./with-resolvers.ts"; + +export interface FutureWithResolvers { + future: Future; + resolve(value: T): void; + reject(error: Error): void; +} +export function createFuture(): FutureWithResolvers { + let promise = lazyPromiseWithResolvers(); + let operation = withResolvers(); + + let resolve = (value: T) => { + promise.resolve(value); + operation.resolve(value); + }; + + let reject = (error: Error) => { + promise.reject(error); + operation.reject(error); + }; + + let future = Object.defineProperties(promise.promise, { + [Symbol.iterator]: { + enumerable: false, + value: operation.operation[Symbol.iterator], + }, + [Symbol.toStringTag]: { + enumerable: false, + value: "Future", + }, + }) as Future; + + return { future, resolve, reject }; +} diff --git a/lib/reducer.ts b/lib/reducer.ts index 4753badee..85bb66799 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -31,7 +31,7 @@ export class Reducer { notify({ done: true, value: Ok(next.value) }); } else { let action = next.value; - routine.data.discard = action.enter(routine.next, routine); + routine.data.exit = action.enter(routine.next, routine); } } else if (iterator.return) { let next = iterator.return(result.value); @@ -39,7 +39,7 @@ export class Reducer { notify({ done: true, value: Ok(result) }); } else { let action = next.value; - routine.data.discard = action.enter(routine.next, routine); + routine.data.exit = action.enter(routine.next, routine); } } else { notify({ done: true, value: result }); @@ -50,7 +50,7 @@ export class Reducer { notify({ done: true, value: Ok(next.value) }); } else { let action = next.value; - routine.data.discard = action.enter(routine.next, routine); + routine.data.exit = action.enter(routine.next, routine); } } else { throw result.error; diff --git a/lib/task.ts b/lib/task.ts index 6f3b5b370..2aaf8fabd 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,13 +1,10 @@ -import { createContext } from "./context.ts"; -import { Routine } from "./contexts.ts"; import { createCoroutine } from "./coroutine.ts"; -import { drain } from "./drain.ts"; -import { lazyPromise, lazyPromiseWithResolvers } from "./lazy-promise.ts"; -import { Just, type Maybe, Nothing } from "./maybe.ts"; -import { Err, Ok, type Result, unbox } from "./result.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; -import type { Coroutine, Operation, Resolve, Scope, Task } from "./types.ts"; -import { withResolvers } from "./with-resolvers.ts"; +import type { Coroutine, Operation, Scope, Task } from "./types.ts"; +import { box } from "./box.ts"; +import { Err, Ok, type Result } from "./result.ts"; +import { createFuture } from "./future.ts"; +import assert from "node:assert"; export interface TaskOptions { owner: ScopeInternal; @@ -16,243 +13,384 @@ export interface TaskOptions { export interface NewTask { scope: Scope; - routine: Coroutine>; + routine: Coroutine; task: Task; start(): void; } +export type TaskState = { + status: "pending"; + halted: boolean; +} | { + status: "finalizing"; + halted: boolean; + computation: Result; + finalization: Result; +} | { + status: "finalized"; + halted: boolean; + computation: Result; + finalization: Result; +}; + export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; let [scope, destroy] = createScopeInternal(owner); - TaskGroup.ensureOwn(scope); - - let routine = createCoroutine({ - scope, - operation: () => trapset(() => after(operation, destroy)), - }); - - let promise = lazyPromiseWithResolvers(); - let future = withResolvers(); - - let resolve = (value: T) => { - promise.resolve(value); - future.resolve(value); + let state: { current: TaskState } = { + current: { status: "pending", halted: false }, }; - let reject = (error: Error) => { - promise.reject(error); - future.reject(error); - }; + let future = createFuture(); - let initiateHalt = (resolve: Resolve>) => { - if (scope.hasOwn(TrapContext)) { - let trap = scope.expect(TrapContext); - let current = routine.data.discard; - routine.data.discard = (exit) => - current((result) => { - if (!result.ok) { - trap.result = result; - } - exit(result); - }); - return routine.return( - trap.result = Ok(Nothing()), - drain((result) => resolve(result.ok ? Ok() : result)), - ); - } else { - return routine.return( - Ok(Nothing()), - drain((result) => resolve(result.ok ? Ok() : result)), - ); - } - }; - - let halt = lazyPromise((resolve, reject) => { - initiateHalt((result) => result.ok ? resolve() : reject(result.error)); - }); + let halted = createFuture(); - Object.defineProperty(halt, Symbol.iterator, { - enumerable: false, - *value(): Operation { - yield ({ - description: "halt", - enter: (resolve) => { - let unsubscribe = initiateHalt(resolve); - - return (done) => { - unsubscribe(); - done(Ok()); + let routine = createCoroutine({ + scope, + *operation() { + let outcome: Result | undefined = undefined; + try { + outcome = yield* box(operation); + try { + state.current = { + status: "finalizing", + halted: state.current.halted, + computation: outcome, + finalization: state.current.halted + ? outcome.ok ? Ok() : outcome + : Ok(), + }; + yield* destroy(); + state.current = { + status: "finalized", + halted: state.current.halted, + computation: outcome, + finalization: state.current.finalization, + }; + } catch (error) { + state.current = { + status: "finalized", + halted: state.current.halted, + computation: outcome, + finalization: Err(error as Error), }; - }, - }); + } + } finally { + if (state.current.status === "pending") { + state.current = { + status: "finalized", + halted: true, + computation: Err(new Error("halted")), + finalization: Ok(), + }; + } + let { current } = state; + assert(current.status === "finalized"); + if (!current.halted) { + halted.resolve(); + if (current.computation.ok) { + future.resolve(current.computation.value); + } else { + future.reject(current.computation.error); + } + } else { + future.reject(new Error("halted")); + let { finalization } = current; + if (!finalization.ok) { + halted.reject(finalization.error); + } else { + halted.resolve(); + } + } + } }, }); - let task = Object.defineProperties(promise.promise, { - [Symbol.toStringTag]: { + let halt = () => { + halt = () => {}; + state.current.halted = true; + routine.return(Ok()); + }; + + let task = Object.defineProperties(future.future, { + halt: { enumerable: false, - value: "Task", + value() { + return Object.defineProperties(Object.create(Promise.prototype), { + [Symbol.iterator]: { + enumerable: false, + *value() { + halt(); + yield* halted.future; + }, + }, + then: { + enumerable: false, + value(...args: Parameters) { + halt(); + return halted.future.then(...args); + }, + }, + catch: { + enumerable: false, + value(...args: Parameters) { + halt(); + return halted.future.catch(...args); + }, + }, + finally: { + enumerable: false, + value(...args: Parameters) { + halt(); + return halted.future.finally(...args); + }, + }, + }); + }, }, [Symbol.iterator]: { enumerable: false, - value: future.operation[Symbol.iterator], - }, - halt: { - enumerable: false, - value: () => halt, + value: future.future[Symbol.iterator], }, }) as Task; - let group = TaskGroup.ensureOwn(owner); - - let link = group.link(owner, task); - - scope.set(Routine, routine); - - let start = () => - routine.next( - Ok(), - drain((result) => { - link.close(result); - if (result.ok) { - if (result.value.exists) { - resolve(result.value.value); - } else { - reject(new Error("halted")); - } - } else { - reject(result.error); - } - }), - ); + let start = () => routine.next(Ok()); return { task, scope, routine, start }; } -export const TaskGroupContext = createContext("@effection/tasks"); - -export function encapsulate(operation: () => Operation): Operation { - return TaskGroupContext.with(new TaskGroup(), function* (group) { - try { - return yield* operation(); - } finally { - yield* group.halt(); - } - }); -} - -class TaskGroup { - static ensureOwn(scope: ScopeInternal): TaskGroup { - if (!scope.hasOwn(TaskGroupContext)) { - let group = scope.set(TaskGroupContext, new TaskGroup()); - scope.ensure(() => group.halt()); - } - return scope.expect(TaskGroupContext); - } - - links = new Set>(); - - link(owner: Scope, task: Task) { - return new TaskLink(owner, task, this.links); - } - - *halt() { - let links = [...this.links].reverse(); - links.forEach((link) => link.sever()); - let outcome = Ok(); - for (let link of links) { - let result = yield* box(link.task.halt); - if (!result.ok) { - outcome = result; - } - } - return unbox(outcome); - } -} - -class TaskLink { - constructor( - public owner: Scope, - public task: Task, - public links: Set>, - ) { - this.links.add(this); - } - - close(result: Result>) { - this.links.delete(this); - if (!result.ok) { - let trap = this.owner.get(TrapContext); - if (trap) { - trap.result = result; - this.owner.get(Routine)?.return(trap.result); - } - } - } - - sever() { - this.links.delete(this); - this.close = () => {}; - } -} - -function* box(op: () => Operation): Operation> { - try { - return Ok(yield* op()); - } catch (error) { - return Err(error as Error); - } +export function trap(op: () => Operation): Operation { + return op(); } -const TrapContext = createContext<{ result: Result> }>( - "@effection/trap", -); - -function trapset(op: () => Operation): Operation> { - let result = Ok(Nothing()); - return TrapContext.with({ result }, function* (trap) { - try { - let value = yield* op(); - if (trap.result === result) { - trap.result = Ok(Just(value)); - } - } catch (error) { - trap.result = Err(error as Error); - } finally { - // deno-lint-ignore no-unsafe-finally - return unbox(trap.result) as Maybe; - } - }); +export function encapsulate(op: () => Operation): Operation { + return op(); } -export function* trap(op: () => Operation): Operation { - let outcome = yield* trapset(op); - if (outcome.exists) { - return outcome.value; - } else { - return (yield { - description: "propagate halt", - enter: (resolve, routine) => { - let trap = routine.scope.expect(TrapContext); - trap.result = Ok(Nothing()); - routine.return(trap.result); - resolve(Ok()); - return (resolve) => { - resolve(Ok()); - }; - }, - }) as T; - } -} - -function* after( - op: () => Operation, - epilogue: () => Operation, -): Operation { - try { - return yield* op(); - } finally { - yield* epilogue(); - } -} +// export function createTask(options: TaskOptions): NewTask { +// let { owner, operation } = options; +// let [scope, destroy] = createScopeInternal(owner); + +// TaskGroup.ensureOwn(scope); + +// let routine = createCoroutine({ +// scope, +// operation: () => trapset(() => after(operation, destroy)), +// }); + +// let promise = lazyPromiseWithResolvers(); +// let future = withResolvers(); + +// let resolve = (value: T) => { +// promise.resolve(value); +// future.resolve(value); +// }; + +// let reject = (error: Error) => { +// promise.reject(error); +// future.reject(error); +// }; + +// let initiateHalt = (resolve: Resolve>) => { +// if (scope.hasOwn(TrapContext)) { +// let trap = scope.expect(TrapContext); +// let current = routine.data.discard; +// routine.data.discard = (exit) => +// current((result) => { +// if (!result.ok) { +// trap.result = result; +// } +// exit(result); +// }); +// return routine.return( +// trap.result = Ok(Nothing()), +// drain((result) => resolve(result.ok ? Ok() : result)), +// ); +// } else { +// return routine.return( +// Ok(Nothing()), +// drain((result) => resolve(result.ok ? Ok() : result)), +// ); +// } +// }; + +// let halt = lazyPromise((resolve, reject) => { +// initiateHalt((result) => result.ok ? resolve() : reject(result.error)); +// }); + +// Object.defineProperty(halt, Symbol.iterator, { +// enumerable: false, +// *value(): Operation { +// yield ({ +// description: "halt", +// enter: (resolve) => { +// let unsubscribe = initiateHalt(resolve); + +// return (done) => { +// unsubscribe(); +// done(Ok()); +// }; +// }, +// }); +// }, +// }); + +// let task = Object.defineProperties(promise.promise, { +// [Symbol.toStringTag]: { +// enumerable: false, +// value: "Task", +// }, +// [Symbol.iterator]: { +// enumerable: false, +// value: future.operation[Symbol.iterator], +// }, +// halt: { +// enumerable: false, +// value: () => halt, +// }, +// }) as Task; + +// let group = TaskGroup.ensureOwn(owner); + +// let link = group.link(owner, task); + +// scope.set(Routine, routine); + +// let start = () => +// routine.next( +// Ok(), +// drain((result) => { +// link.close(result); +// if (result.ok) { +// if (result.value.exists) { +// resolve(result.value.value); +// } else { +// reject(new Error("halted")); +// } +// } else { +// reject(result.error); +// } +// }), +// ); + +// return { task, scope, routine, start }; +// } + +// export const TaskGroupContext = createContext("@effection/tasks"); + +// export function encapsulate(operation: () => Operation): Operation { +// return TaskGroupContext.with(new TaskGroup(), function* (group) { +// try { +// return yield* operation(); +// } finally { +// yield* group.halt(); +// } +// }); +// } + +// class TaskGroup { +// static ensureOwn(scope: ScopeInternal): TaskGroup { +// if (!scope.hasOwn(TaskGroupContext)) { +// let group = scope.set(TaskGroupContext, new TaskGroup()); +// scope.ensure(() => group.halt()); +// } +// return scope.expect(TaskGroupContext); +// } + +// links = new Set>(); + +// link(owner: Scope, task: Task) { +// return new TaskLink(owner, task, this.links); +// } + +// *halt() { +// let links = [...this.links].reverse(); +// links.forEach((link) => link.sever()); +// let outcome = Ok(); +// for (let link of links) { +// let result = yield* box(link.task.halt); +// if (!result.ok) { +// outcome = result; +// } +// } +// return unbox(outcome); +// } +// } + +// class TaskLink { +// constructor( +// public owner: Scope, +// public task: Task, +// public links: Set>, +// ) { +// this.links.add(this); +// } + +// close(result: Result>) { +// this.links.delete(this); +// if (!result.ok) { +// let trap = this.owner.get(TrapContext); +// if (trap) { +// trap.result = result; +// this.owner.get(Routine)?.return(trap.result); +// } +// } +// } + +// sever() { +// this.links.delete(this); +// this.close = () => {}; +// } +// } + +// const TrapContext = createContext<{ result: Result> }>( +// "@effection/trap", +// ); + +// function trapset(op: () => Operation): Operation> { +// let result = Ok(Nothing()); +// return TrapContext.with({ result }, function* (trap) { +// try { +// let value = yield* op(); +// if (trap.result === result) { +// trap.result = Ok(Just(value)); +// } +// } catch (error) { +// trap.result = Err(error as Error); +// } finally { +// // deno-lint-ignore no-unsafe-finally +// return unbox(trap.result) as Maybe; +// } +// }); +// } + +// export function* trap(op: () => Operation): Operation { +// let outcome = yield* trapset(op); +// if (outcome.exists) { +// return outcome.value; +// } else { +// return (yield { +// description: "propagate halt", +// enter: (resolve, routine) => { +// let trap = routine.scope.expect(TrapContext); +// trap.result = Ok(Nothing()); +// routine.return(trap.result); +// resolve(Ok()); +// return (resolve) => { +// resolve(Ok()); +// }; +// }, +// }) as T; +// } +// } + +// function* after( +// op: () => Operation, +// epilogue: () => Operation, +// ): Operation { +// try { +// return yield* op(); +// } finally { +// yield* epilogue(); +// } +// } diff --git a/lib/types.ts b/lib/types.ts index 70d01edf3..b12cab336 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -357,7 +357,7 @@ export interface Effect { export interface Coroutine { scope: Scope; data: { - discard(resolve: Resolve>): void; + exit(resolve: Resolve>): void; iterator: Iterator, T, unknown>; }; next(result: Result, subscriber?: Subscriber): () => void; diff --git a/test/run.test.ts b/test/run.test.ts index 7ea683f96..910b94117 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -1,14 +1,7 @@ // deno-lint-ignore-file no-unsafe-finally + import { blowUp, createNumber, describe, expect, it } from "./suite.ts"; -import { - action, - run, - sleep, - spawn, - suspend, - type Task, - until, -} from "../mod.ts"; +import { action, run, sleep, spawn, suspend, Task, until } from "../mod.ts"; describe("run()", () => { it("can run an operation", async () => { @@ -161,20 +154,20 @@ describe("run()", () => { expect(completed).toEqual(true); }); - // // it("cannot explicitly suspend in a finally block", async () => { - // // let done = false; - // // let task = run(function* () { - // // try { - // // yield* suspend(); - // // } finally { - // // yield* suspend(); - // // done = true; - // // } - // // }); - - // // await run(task.halt); - // // expect(done).toEqual(true); - // // }); + // it("cannot explicitly suspend in a finally block", async () => { + // let done = false; + // let task = run(function* () { + // try { + // yield* suspend(); + // } finally { + // yield* suspend(); + // done = true; + // } + // }); + + // await run(task.halt); + // expect(done).toEqual(true); + // }); it("can suspend in yielded finally block", async () => { let things: string[] = []; @@ -200,7 +193,7 @@ describe("run()", () => { expect(things).toEqual(["first", "second"]); }); - it("can be halted while in the generator", async () => { + it.skip("can be halted while in the generator", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -235,7 +228,7 @@ describe("run()", () => { await expect(task).rejects.toMatchObject({ message: "halted" }); }); - it("can delay halt if child fails", async () => { + it.skip("can delay halt if child fails", async () => { let didRun = false; let task = run(function* Main() { yield* spawn(function* willBoom() { @@ -253,7 +246,7 @@ describe("run()", () => { expect(didRun).toEqual(true); }); - it("handles error in entering suspend point", async () => { + it.skip("handles error in entering suspend point", async () => { let error = new Error("boom!"); let task = run(function* () { yield* action(() => { @@ -275,7 +268,7 @@ describe("run()", () => { await expect(task.halt()).rejects.toEqual(error); }); - it("can throw error when child blows up", async () => { + it.skip("can throw error when child blows up", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -290,46 +283,46 @@ describe("run()", () => { await expect(task).rejects.toHaveProperty("message", "bang"); }); - it("throws an error in halt() if its finally block blows up", async () => { - let task = run(function* main() { - try { - yield* suspend(); - } finally { - throw new Error("moo"); - } - }); - - await expect(task.halt()).rejects.toMatchObject({ message: "moo" }); - }); - - it("propagates errors", async () => { - try { - await run(function* () { - throw new Error("boom"); - }); - throw new Error("expected error to propagate"); - } catch (error) { - expect((error as Error).message).toEqual("boom"); - } - }); - - it("propagates errors from promises", async () => { - try { - await run(function* () { - yield* until(Promise.reject(new Error("boom"))); - }); - throw new Error("expected error to propagate"); - } catch (error) { - expect((error as Error).message).toEqual("boom"); - } - }); - - it("successfully halts when task fails, but shutdown succeeds ", async () => { - let task = run(function* () { - throw new Error("boom!"); - }); - - await expect(task).rejects.toHaveProperty("message", "boom!"); - await expect(task.halt()).resolves.toBe(undefined); - }); + // it("throws an error in halt() if its finally block blows up", async () => { + // let task = run(function* main() { + // try { + // yield* suspend(); + // } finally { + // throw new Error("moo"); + // } + // }); + + // await expect(task.halt()).rejects.toMatchObject({ message: "moo" }); + // }); + + // it("propagates errors", async () => { + // try { + // await run(function* () { + // throw new Error("boom"); + // }); + // throw new Error("expected error to propagate"); + // } catch (error) { + // expect((error as Error).message).toEqual("boom"); + // } + // }); + + // it("propagates errors from promises", async () => { + // try { + // await run(function* () { + // yield* until(Promise.reject(new Error("boom"))); + // }); + // throw new Error("expected error to propagate"); + // } catch (error) { + // expect((error as Error).message).toEqual("boom"); + // } + // }); + + // it("successfully halts when task fails, but shutdown succeeds ", async () => { + // let task = run(function* () { + // throw new Error("boom!"); + // }); + + // await expect(task).rejects.toHaveProperty("message", "boom!"); + // await expect(task.halt()).resolves.toBe(undefined); + // }); }); From 3b12bc9d8aaeda64268123813a3ccfce824ccfda Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 13 May 2025 17:32:21 -0500 Subject: [PATCH 019/119] simplify coroutine api --- lib/coroutine.ts | 32 +++---------------- lib/reducer.ts | 13 ++++---- lib/task.ts | 62 ++++++++++++++++++++++++++++++++++++ lib/types.ts | 4 +-- test/run.test.ts | 82 ++++++++++++++++++++++++------------------------ 5 files changed, 116 insertions(+), 77 deletions(-) diff --git a/lib/coroutine.ts b/lib/coroutine.ts index a205dad34..13cc6c3c0 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -11,19 +11,6 @@ export interface CoroutineOptions { export function createCoroutine( { operation, scope }: CoroutineOptions, ): Coroutine { - let subscribers = new Set>(); - - let send: Subscriber = (item) => { - try { - for (let subscriber of subscribers) { - subscriber(item); - } - } finally { - if (item.done) { - subscribers.clear(); - } - } - }; let reducer = scope.expect(ReducerContext); @@ -40,40 +27,29 @@ export function createCoroutine( }, exit: (resolve) => resolve(Ok()), }, - next(result, subscriber) { - if (subscriber) { - subscribers.add(subscriber); - } + next(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ scope.expect(Generation), routine, exitResult.ok ? result : exitResult, - send as Subscriber, + () => {}, "next", ]); }); - - return () => subscriber && subscribers.delete(subscriber); }, - return(result, subscriber?: Subscriber) { - if (subscriber) { - subscribers.add(subscriber as Subscriber); - } + return(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ scope.expect(Generation), routine, exitResult.ok ? result : exitResult, - send as Subscriber, + () => {}, "return", ]); }); - - return () => - subscriber && subscribers.delete(subscriber as Subscriber); }, } as Coroutine; diff --git a/lib/reducer.ts b/lib/reducer.ts index 85bb66799..dac7b4ae8 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -22,13 +22,13 @@ export class Reducer { while (item) { let [, routine, result, notify, method = "next" as const] = item; try { - notify({ done: false, value: result }); + // notify({ done: false, value: result }); const iterator = routine.data.iterator; if (result.ok) { if (method === "next") { let next = iterator.next(result.value); if (next.done) { - notify({ done: true, value: Ok(next.value) }); + // notify({ done: true, value: Ok(next.value) }); } else { let action = next.value; routine.data.exit = action.enter(routine.next, routine); @@ -36,18 +36,18 @@ export class Reducer { } else if (iterator.return) { let next = iterator.return(result.value); if (next.done) { - notify({ done: true, value: Ok(result) }); + // notify({ done: true, value: Ok(result) }); } else { let action = next.value; routine.data.exit = action.enter(routine.next, routine); } } else { - notify({ done: true, value: result }); + // notify({ done: true, value: result }); } } else if (iterator.throw) { let next = iterator.throw(result.error); if (next.done) { - notify({ done: true, value: Ok(next.value) }); + // notify({ done: true, value: Ok(next.value) }); } else { let action = next.value; routine.data.exit = action.enter(routine.next, routine); @@ -56,7 +56,8 @@ export class Reducer { throw result.error; } } catch (error) { - notify({ done: true, value: Err(error as Error) }); + routine.next(Err(error as Error)); + // notify({ done: true, value: Err(error as Error) }); } item = queue.dequeue(); } diff --git a/lib/task.ts b/lib/task.ts index 2aaf8fabd..f9edfb274 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -5,6 +5,7 @@ import { box } from "./box.ts"; import { Err, Ok, type Result } from "./result.ts"; import { createFuture } from "./future.ts"; import assert from "node:assert"; +import { createContext } from "./context.ts"; export interface TaskOptions { owner: ScopeInternal; @@ -37,6 +38,11 @@ export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; let [scope, destroy] = createScopeInternal(owner); + let link = owner.expect(TaskLinkContext); + let children = new TaskTree((error) => routine.return(Err(error))); + scope.set(TaskLinkContext, children); + scope.ensure(() => children.destroy()); + let state: { current: TaskState } = { current: { status: "pending", halted: false }, }; @@ -60,6 +66,7 @@ export function createTask(options: TaskOptions): NewTask { ? outcome.ok ? Ok() : outcome : Ok(), }; + yield* destroy(); state.current = { status: "finalized", @@ -86,6 +93,7 @@ export function createTask(options: TaskOptions): NewTask { } let { current } = state; assert(current.status === "finalized"); + link.finalized(task, current); if (!current.halted) { halted.resolve(); if (current.computation.ok) { @@ -154,11 +162,65 @@ export function createTask(options: TaskOptions): NewTask { }, }) as Task; + link.add(task); + let start = () => routine.next(Ok()); return { task, scope, routine, start }; } +const TaskLinkContext = createContext("@effection/tasks", { + add() {}, + finalized() {}, +}); + +interface TaskLink { + add(task: Task): void; + finalized( + task: Task, + state: Extract, { status: "finalized" }>, + ): void; +} + +class TaskTree implements TaskLink { + tasks = new Set>(); + constructor(public crash: (error: Error) => void) {} + add(task: Task) { + this.tasks.add(task); + } + finalized( + task: Task, + state: Extract, { status: "finalized" }>, + ) { + this.tasks.delete(task); + if (!state.finalization.ok) { + this.crash(state.finalization.error); + } else if (!state.halted && !state.computation.ok) { + this.crash(state.computation.error); + } + } + + *destroy() { + let result = Ok(); + while (this.tasks.size > 0) { + let tasks = [...this.tasks].reverse(); + for (let task of tasks) { + this.tasks.delete(task); + } + for (let task of tasks) { + try { + yield* task.halt(); + } catch (error) { + result = Err(error as Error); + } + } + } + if (!result.ok) { + throw result.error; + } + } +} + export function trap(op: () => Operation): Operation { return op(); } diff --git a/lib/types.ts b/lib/types.ts index b12cab336..272fd2d18 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -360,8 +360,8 @@ export interface Coroutine { exit(resolve: Resolve>): void; iterator: Iterator, T, unknown>; }; - next(result: Result, subscriber?: Subscriber): () => void; - return(result: Result, subcriber?: Subscriber): () => void; + next(result: Result): void; + return(result: Result): void; } /** diff --git a/test/run.test.ts b/test/run.test.ts index 910b94117..83ee6599a 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -193,7 +193,7 @@ describe("run()", () => { expect(things).toEqual(["first", "second"]); }); - it.skip("can be halted while in the generator", async () => { + it("can be halted while in the generator", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -228,7 +228,7 @@ describe("run()", () => { await expect(task).rejects.toMatchObject({ message: "halted" }); }); - it.skip("can delay halt if child fails", async () => { + it("can delay halt if child fails", async () => { let didRun = false; let task = run(function* Main() { yield* spawn(function* willBoom() { @@ -246,7 +246,7 @@ describe("run()", () => { expect(didRun).toEqual(true); }); - it.skip("handles error in entering suspend point", async () => { + it("handles error in entering suspend point", async () => { let error = new Error("boom!"); let task = run(function* () { yield* action(() => { @@ -268,7 +268,7 @@ describe("run()", () => { await expect(task.halt()).rejects.toEqual(error); }); - it.skip("can throw error when child blows up", async () => { + it("can throw error when child blows up", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -283,46 +283,46 @@ describe("run()", () => { await expect(task).rejects.toHaveProperty("message", "bang"); }); - // it("throws an error in halt() if its finally block blows up", async () => { - // let task = run(function* main() { - // try { - // yield* suspend(); - // } finally { - // throw new Error("moo"); - // } - // }); + it.skip("throws an error in halt() if its finally block blows up", async () => { + let task = run(function* main() { + try { + yield* suspend(); + } finally { + throw new Error("moo"); + } + }); - // await expect(task.halt()).rejects.toMatchObject({ message: "moo" }); - // }); + await expect(task.halt()).rejects.toMatchObject({ message: "moo" }); + }); - // it("propagates errors", async () => { - // try { - // await run(function* () { - // throw new Error("boom"); - // }); - // throw new Error("expected error to propagate"); - // } catch (error) { - // expect((error as Error).message).toEqual("boom"); - // } - // }); + it("propagates errors", async () => { + try { + await run(function* () { + throw new Error("boom"); + }); + throw new Error("expected error to propagate"); + } catch (error) { + expect((error as Error).message).toEqual("boom"); + } + }); - // it("propagates errors from promises", async () => { - // try { - // await run(function* () { - // yield* until(Promise.reject(new Error("boom"))); - // }); - // throw new Error("expected error to propagate"); - // } catch (error) { - // expect((error as Error).message).toEqual("boom"); - // } - // }); + it("propagates errors from promises", async () => { + try { + await run(function* () { + yield* until(Promise.reject(new Error("boom"))); + }); + throw new Error("expected error to propagate"); + } catch (error) { + expect((error as Error).message).toEqual("boom"); + } + }); - // it("successfully halts when task fails, but shutdown succeeds ", async () => { - // let task = run(function* () { - // throw new Error("boom!"); - // }); + it("successfully halts when task fails, but shutdown succeeds ", async () => { + let task = run(function* () { + throw new Error("boom!"); + }); - // await expect(task).rejects.toHaveProperty("message", "boom!"); - // await expect(task.halt()).resolves.toBe(undefined); - // }); + await expect(task).rejects.toHaveProperty("message", "boom!"); + await expect(task.halt()).resolves.toBe(undefined); + }); }); From b8251d8f425f9892a2045c67f7d316513e20cd78 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 14 May 2025 13:14:02 -0500 Subject: [PATCH 020/119] get some tests working mostly --- lib/future.ts | 1 + lib/reducer.ts | 4 ++ lib/task.ts | 95 +++++++++++++++++++++++++----------------------- test/run.test.ts | 4 +- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/lib/future.ts b/lib/future.ts index 7e465f7ec..b61517651 100644 --- a/lib/future.ts +++ b/lib/future.ts @@ -28,6 +28,7 @@ export function createFuture(): FutureWithResolvers { }, [Symbol.toStringTag]: { enumerable: false, + configurable: true, value: "Future", }, }) as Future; diff --git a/lib/reducer.ts b/lib/reducer.ts index dac7b4ae8..52bd55fb9 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -34,8 +34,11 @@ export class Reducer { routine.data.exit = action.enter(routine.next, routine); } } else if (iterator.return) { + let next = iterator.return(result.value); if (next.done) { + console.log({ return: next }); + // notify({ done: true, value: Ok(result) }); } else { let action = next.value; @@ -56,6 +59,7 @@ export class Reducer { throw result.error; } } catch (error) { + // console.log({ error }); routine.next(Err(error as Error)); // notify({ done: true, value: Err(error as Error) }); } diff --git a/lib/task.ts b/lib/task.ts index f9edfb274..7ca694abd 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -4,8 +4,8 @@ import type { Coroutine, Operation, Scope, Task } from "./types.ts"; import { box } from "./box.ts"; import { Err, Ok, type Result } from "./result.ts"; import { createFuture } from "./future.ts"; -import assert from "node:assert"; import { createContext } from "./context.ts"; +import { Do } from "./do.ts"; export interface TaskOptions { owner: ScopeInternal; @@ -57,57 +57,58 @@ export function createTask(options: TaskOptions): NewTask { let outcome: Result | undefined = undefined; try { outcome = yield* box(operation); - try { - state.current = { - status: "finalizing", - halted: state.current.halted, - computation: outcome, - finalization: state.current.halted - ? outcome.ok ? Ok() : outcome - : Ok(), - }; - - yield* destroy(); - state.current = { - status: "finalized", - halted: state.current.halted, - computation: outcome, - finalization: state.current.finalization, - }; - } catch (error) { - state.current = { - status: "finalized", - halted: state.current.halted, - computation: outcome, - finalization: Err(error as Error), - }; - } } finally { - if (state.current.status === "pending") { - state.current = { - status: "finalized", - halted: true, - computation: Err(new Error("halted")), - finalization: Ok(), - }; - } - let { current } = state; - assert(current.status === "finalized"); - link.finalized(task, current); - if (!current.halted) { + + // yield { + // description: "some effect", + // enter(resolve) { + // resolve(Ok()); + // return (didExit) => { + // didExit(Ok()); + // }; + // }, + // }; + + let computation = outcome ?? Err(new Error("halted")); + + let finalizing = state.current = { + status: "finalizing", + halted: state.current.halted, + computation, + finalization: Ok(), + }; + + let finalization = yield* box(destroy); + + let finalized = state.current = { + status: "finalized", + halted: state.current.halted, + computation: finalizing.computation, + finalization: + finalization.ok && finalizing.halted && outcome && !outcome.ok + ? outcome + : finalization, + }; + + console.log({ finalized }); + + link.finalized(task, finalized); + + if (!finalized.halted) { halted.resolve(); - if (current.computation.ok) { - future.resolve(current.computation.value); + if (!finalization.ok) { + future.reject(finalization.error); + } else if (!computation.ok) { + future.reject(computation.error); } else { - future.reject(current.computation.error); + future.resolve(computation.value); } } else { future.reject(new Error("halted")); - let { finalization } = current; - if (!finalization.ok) { - halted.reject(finalization.error); - } else { + if (finalized.finalization.ok) { halted.resolve(); + } else { + halted.reject(finalized.finalization.error); } } } @@ -160,6 +161,10 @@ export function createTask(options: TaskOptions): NewTask { enumerable: false, value: future.future[Symbol.iterator], }, + [Symbol.toStringTag]: { + enumerable: false, + value: "Task", + }, }) as Task; link.add(task); diff --git a/test/run.test.ts b/test/run.test.ts index 83ee6599a..4671bac2b 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -154,7 +154,7 @@ describe("run()", () => { expect(completed).toEqual(true); }); - // it("cannot explicitly suspend in a finally block", async () => { + // it.skip("cannot explicitly suspend in a finally block", async () => { // let done = false; // let task = run(function* () { // try { @@ -283,7 +283,7 @@ describe("run()", () => { await expect(task).rejects.toHaveProperty("message", "bang"); }); - it.skip("throws an error in halt() if its finally block blows up", async () => { + it.only("throws an error in halt() if its finally block blows up", async () => { let task = run(function* main() { try { yield* suspend(); From 6be60c153f292a106f7f0026f047e2cb0ea8d7a4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 14 May 2025 14:21:25 -0500 Subject: [PATCH 021/119] leaner, meaner trapset --- lib/coroutine.ts | 1 - lib/reducer.ts | 20 +++----- lib/task.ts | 128 +++++++++++++++++++++++++---------------------- test/run.test.ts | 2 +- 4 files changed, 77 insertions(+), 74 deletions(-) diff --git a/lib/coroutine.ts b/lib/coroutine.ts index 13cc6c3c0..93124732d 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -11,7 +11,6 @@ export interface CoroutineOptions { export function createCoroutine( { operation, scope }: CoroutineOptions, ): Coroutine { - let reducer = scope.expect(ReducerContext); let iterator: Coroutine["data"]["iterator"] | undefined = undefined; diff --git a/lib/reducer.ts b/lib/reducer.ts index 52bd55fb9..96e020c36 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -22,35 +22,31 @@ export class Reducer { while (item) { let [, routine, result, notify, method = "next" as const] = item; try { - // notify({ done: false, value: result }); + // notify({ done: false, value: result }); const iterator = routine.data.iterator; if (result.ok) { if (method === "next") { let next = iterator.next(result.value); if (next.done) { - // notify({ done: true, value: Ok(next.value) }); + // notify({ done: true, value: Ok(next.value) }); } else { let action = next.value; routine.data.exit = action.enter(routine.next, routine); } } else if (iterator.return) { - let next = iterator.return(result.value); - if (next.done) { - console.log({ return: next }); - - // notify({ done: true, value: Ok(result) }); + if (next.done) { // notify({ done: true, value: Ok(result) }); } else { let action = next.value; routine.data.exit = action.enter(routine.next, routine); } } else { - // notify({ done: true, value: result }); + // notify({ done: true, value: result }); } } else if (iterator.throw) { let next = iterator.throw(result.error); if (next.done) { - // notify({ done: true, value: Ok(next.value) }); + // notify({ done: true, value: Ok(next.value) }); } else { let action = next.value; routine.data.exit = action.enter(routine.next, routine); @@ -59,9 +55,9 @@ export class Reducer { throw result.error; } } catch (error) { - // console.log({ error }); - routine.next(Err(error as Error)); - // notify({ done: true, value: Err(error as Error) }); + // console.log({ error }); + routine.next(Err(error as Error)); + // notify({ done: true, value: Err(error as Error) }); } item = queue.dequeue(); } diff --git a/lib/task.ts b/lib/task.ts index 7ca694abd..86320f8f5 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -5,7 +5,7 @@ import { box } from "./box.ts"; import { Err, Ok, type Result } from "./result.ts"; import { createFuture } from "./future.ts"; import { createContext } from "./context.ts"; -import { Do } from "./do.ts"; +import { Just, type Maybe, Nothing } from "./maybe.ts"; export interface TaskOptions { owner: ScopeInternal; @@ -34,6 +34,34 @@ export type TaskState = { finalization: Result; }; +interface Trap { + outcome: Result>; +} + +function* trapset( + trap: Trap, + op: () => Operation, +): Operation>> { + let original = trap.outcome; + try { + let value = yield* op(); + if (trap.outcome === original) { + trap.outcome = Ok(Just(value)); + } + } catch (error) { + trap.outcome = Err(error as Error); + } finally { + // deno-lint-ignore no-unsafe-finally + return (yield { + description: "trapset return", + enter(resolve) { + resolve(Ok(trap.outcome)); + return (didExit) => didExit(Ok()); + }, + }) as typeof trap.outcome; + } +} + export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; let [scope, destroy] = createScopeInternal(owner); @@ -51,66 +79,43 @@ export function createTask(options: TaskOptions): NewTask { let halted = createFuture(); + let trap: Trap = { + outcome: Ok(Nothing()), + }; + let routine = createCoroutine({ scope, *operation() { - let outcome: Result | undefined = undefined; - try { - outcome = yield* box(operation); - } finally { - - // yield { - // description: "some effect", - // enter(resolve) { - // resolve(Ok()); - // return (didExit) => { - // didExit(Ok()); - // }; - // }, - // }; - - let computation = outcome ?? Err(new Error("halted")); - - let finalizing = state.current = { - status: "finalizing", - halted: state.current.halted, - computation, - finalization: Ok(), - }; - - let finalization = yield* box(destroy); - - let finalized = state.current = { - status: "finalized", - halted: state.current.halted, - computation: finalizing.computation, - finalization: - finalization.ok && finalizing.halted && outcome && !outcome.ok - ? outcome - : finalization, - }; - - console.log({ finalized }); - - link.finalized(task, finalized); - - if (!finalized.halted) { - halted.resolve(); - if (!finalization.ok) { - future.reject(finalization.error); - } else if (!computation.ok) { - future.reject(computation.error); - } else { - future.resolve(computation.value); - } + let outcome = yield* trapset(trap, operation); + + let finalization = state.current.halted && !outcome.ok ? outcome : Ok(); + + let destruction = yield* box(destroy); + + finalization = !destruction.ok ? destruction : finalization; + + link.finalized(task, outcome, finalization); + + if (!state.current.halted) { + halted.resolve(); + if (!finalization.ok) { + future.reject(finalization.error); + } else if (!outcome.ok) { + future.reject(outcome.error); } else { - future.reject(new Error("halted")); - if (finalized.finalization.ok) { - halted.resolve(); + if (outcome.value.exists) { + future.resolve(outcome.value.value); } else { - halted.reject(finalized.finalization.error); + future.reject(new Error("halted")); } } + } else { + future.reject(new Error("halted")); + if (finalization.ok) { + halted.resolve(); + } else { + halted.reject(finalization.error); + } } }, }); @@ -118,6 +123,7 @@ export function createTask(options: TaskOptions): NewTask { let halt = () => { halt = () => {}; state.current.halted = true; + trap.outcome = Ok(Nothing()); routine.return(Ok()); }; @@ -183,7 +189,8 @@ interface TaskLink { add(task: Task): void; finalized( task: Task, - state: Extract, { status: "finalized" }>, + outcome: Result>, + finalization: Result, ): void; } @@ -195,13 +202,14 @@ class TaskTree implements TaskLink { } finalized( task: Task, - state: Extract, { status: "finalized" }>, + outcome: Result>, + finalization: Result, ) { this.tasks.delete(task); - if (!state.finalization.ok) { - this.crash(state.finalization.error); - } else if (!state.halted && !state.computation.ok) { - this.crash(state.computation.error); + if (!finalization.ok) { + this.crash(finalization.error); + } else if (!outcome.ok) { + this.crash(outcome.error); } } diff --git a/test/run.test.ts b/test/run.test.ts index 4671bac2b..647d62ddc 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -283,7 +283,7 @@ describe("run()", () => { await expect(task).rejects.toHaveProperty("message", "bang"); }); - it.only("throws an error in halt() if its finally block blows up", async () => { + it("throws an error in halt() if its finally block blows up", async () => { let task = run(function* main() { try { yield* suspend(); From 3d22d742bbdc062aba88f31e93669d037e339cce Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 15 May 2025 15:25:18 -0500 Subject: [PATCH 022/119] handle halt case where coroutine has not even started --- lib/coroutine.ts | 2 +- lib/reducer.ts | 4 ++-- lib/task.ts | 19 ++++++++++++------- test/run.test.ts | 2 +- test/spawn.test.ts | 8 ++++---- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/coroutine.ts b/lib/coroutine.ts index 93124732d..c8178d384 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -1,7 +1,7 @@ import { Generation } from "./contexts.ts"; import { ReducerContext } from "./reducer.ts"; import { Ok } from "./result.ts"; -import type { Coroutine, Operation, Scope, Subscriber } from "./types.ts"; +import type { Coroutine, Operation, Scope } from "./types.ts"; export interface CoroutineOptions { scope: Scope; diff --git a/lib/reducer.ts b/lib/reducer.ts index 96e020c36..609915d0f 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -1,5 +1,5 @@ import { createContext } from "./context.ts"; -import { Err, Ok, type Result } from "./result.ts"; +import { Err, type Result } from "./result.ts"; import type { Coroutine, Subscriber } from "./types.ts"; export class Reducer { @@ -20,7 +20,7 @@ export class Reducer { let item = queue.dequeue(); while (item) { - let [, routine, result, notify, method = "next" as const] = item; + let [, routine, result, _ , method = "next" as const] = item; try { // notify({ done: false, value: result }); const iterator = routine.data.iterator; diff --git a/lib/task.ts b/lib/task.ts index 86320f8f5..d3b18175d 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -79,6 +79,12 @@ export function createTask(options: TaskOptions): NewTask { let halted = createFuture(); + let halt = () => { + halt = () => {}; + halted.resolve(); + future.reject(new Error("halted")); + }; + let trap: Trap = { outcome: Ok(Nothing()), }; @@ -86,6 +92,12 @@ export function createTask(options: TaskOptions): NewTask { let routine = createCoroutine({ scope, *operation() { + halt = () => { + halt = () => {}; + state.current.halted = true; + trap.outcome = Ok(Nothing()); + routine.return(Ok()); + }; let outcome = yield* trapset(trap, operation); let finalization = state.current.halted && !outcome.ok ? outcome : Ok(); @@ -120,13 +132,6 @@ export function createTask(options: TaskOptions): NewTask { }, }); - let halt = () => { - halt = () => {}; - state.current.halted = true; - trap.outcome = Ok(Nothing()); - routine.return(Ok()); - }; - let task = Object.defineProperties(future.future, { halt: { enumerable: false, diff --git a/test/run.test.ts b/test/run.test.ts index 647d62ddc..8f264bd79 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -1,7 +1,7 @@ // deno-lint-ignore-file no-unsafe-finally import { blowUp, createNumber, describe, expect, it } from "./suite.ts"; -import { action, run, sleep, spawn, suspend, Task, until } from "../mod.ts"; +import { action, run, sleep, spawn, suspend, type Task, until } from "../mod.ts"; describe("run()", () => { it("can run an operation", async () => { diff --git a/test/spawn.test.ts b/test/spawn.test.ts index 931cab6e4..fb75b2af6 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -86,7 +86,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it("rejects when child errors during completing", async () => { + it.skip("rejects when child errors during completing", async () => { let child; let root = run(function* root() { child = yield* spawn(function* child() { @@ -104,7 +104,7 @@ describe("spawn", () => { await expect(root).rejects.toHaveProperty("message", "moo"); }); - it("rejects when child errors during halting", async () => { + it.skip("rejects when child errors during halting", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -175,7 +175,7 @@ describe("spawn", () => { ]); }); - it("halts children on explicit halt", async () => { + it.skip("halts children on explicit halt", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -191,7 +191,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it("raises an uncatchable error if a spawned child fails", async () => { + it.skip("raises an uncatchable error if a spawned child fails", async () => { let task = run(function* () { yield* spawn(function* () { yield* sleep(5); From 8d7ab9d653fba7e33af745f92dac3ee0e7a49f4b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 15 May 2025 17:35:05 -0500 Subject: [PATCH 023/119] remove dead branches in reducer --- lib/reducer.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/lib/reducer.ts b/lib/reducer.ts index 609915d0f..3949ca188 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -20,34 +20,26 @@ export class Reducer { let item = queue.dequeue(); while (item) { - let [, routine, result, _ , method = "next" as const] = item; + let [, routine, result, _, method = "next" as const] = item; try { - // notify({ done: false, value: result }); const iterator = routine.data.iterator; if (result.ok) { if (method === "next") { let next = iterator.next(result.value); - if (next.done) { - // notify({ done: true, value: Ok(next.value) }); - } else { + if (!next.done) { let action = next.value; routine.data.exit = action.enter(routine.next, routine); } } else if (iterator.return) { let next = iterator.return(result.value); - if (next.done) { // notify({ done: true, value: Ok(result) }); - } else { + if (!next.done) { let action = next.value; routine.data.exit = action.enter(routine.next, routine); } - } else { - // notify({ done: true, value: result }); } } else if (iterator.throw) { let next = iterator.throw(result.error); - if (next.done) { - // notify({ done: true, value: Ok(next.value) }); - } else { + if (!next.done) { let action = next.value; routine.data.exit = action.enter(routine.next, routine); } @@ -55,9 +47,7 @@ export class Reducer { throw result.error; } } catch (error) { - // console.log({ error }); routine.next(Err(error as Error)); - // notify({ done: true, value: Err(error as Error) }); } item = queue.dequeue(); } From 9eb985786453d1b147a4d839c24291c84b9f82f6 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 16 May 2025 10:05:35 -0500 Subject: [PATCH 024/119] remove defunct `Subscriber` type --- lib/drain.ts | 10 ---------- lib/reducer.ts | 6 +++--- lib/types.ts | 7 ------- 3 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 lib/drain.ts diff --git a/lib/drain.ts b/lib/drain.ts deleted file mode 100644 index c9e47e088..000000000 --- a/lib/drain.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Result } from "./result.ts"; -import type { Subscriber } from "./types.ts"; - -export function drain(end: (result: Result) => void): Subscriber { - return (next) => { - if (next.done) { - end(next.value); - } - }; -} diff --git a/lib/reducer.ts b/lib/reducer.ts index c62b5ca1c..5545f70ae 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -1,7 +1,7 @@ import { createContext } from "./context.ts"; import { PriorityQueue } from "./priority-queue.ts"; -import { Err, Ok, type Result } from "./result.ts"; -import type { Coroutine, Subscriber } from "./types.ts"; +import { Err, type Result } from "./result.ts"; +import type { Coroutine } from "./types.ts"; export class Reducer { reducing = false; @@ -67,7 +67,7 @@ type Thunk = [ number, Coroutine, Result, - Subscriber, + () => void, "return" | "next", number, ]; diff --git a/lib/types.ts b/lib/types.ts index 29c8d7bb5..cd42ab537 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -365,13 +365,6 @@ export interface Coroutine { return(result: Result): void; } -/** - * @ignore - */ -export interface Subscriber { - (result: IteratorResult, Result>): void; -} - /** * @ignore */ From 5d78045d7b7e113367e2a499ca956154a6c70109 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 17 May 2025 11:07:01 -0500 Subject: [PATCH 025/119] All run/spawn tests now passing --- lib/coroutine.ts | 6 +++--- lib/reducer.ts | 2 +- lib/task.ts | 21 ++++++++++++++++----- lib/types.ts | 2 +- test/each.test.ts | 22 +++++++++++----------- test/run.test.ts | 10 +++++++++- test/spawn.test.ts | 14 ++++++-------- 7 files changed, 47 insertions(+), 30 deletions(-) diff --git a/lib/coroutine.ts b/lib/coroutine.ts index fa42982ab..8c6a49665 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -16,7 +16,7 @@ export function createCoroutine( let iterator: Coroutine["data"]["iterator"] | undefined = undefined; let routine = { - version: 0, + runLevel: 0, scope, data: { get iterator() { @@ -36,7 +36,7 @@ export function createCoroutine( exitResult.ok ? result : exitResult, () => {}, "next", - routine.version, + routine.runLevel, ]); }); }, @@ -49,7 +49,7 @@ export function createCoroutine( exitResult.ok ? result : exitResult, () => {}, "return", - routine.version, + routine.runLevel, ]); }); }, diff --git a/lib/reducer.ts b/lib/reducer.ts index 5545f70ae..7ae135e84 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -107,7 +107,7 @@ function createPriorityQueue2() { let top = q.pop(); if (!top) { return undefined; - } else if (top[5] < top[1].version) { + } else if (top[5] < top[1].runLevel) { continue; } else { return top; diff --git a/lib/task.ts b/lib/task.ts index d3b18175d..04714283b 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -67,7 +67,10 @@ export function createTask(options: TaskOptions): NewTask { let [scope, destroy] = createScopeInternal(owner); let link = owner.expect(TaskLinkContext); - let children = new TaskTree((error) => routine.return(Err(error))); + let children = new TaskTree((error) => { + trap.outcome = Err(error); + routine.return(Ok()); + }); scope.set(TaskLinkContext, children); scope.ensure(() => children.destroy()); @@ -81,6 +84,7 @@ export function createTask(options: TaskOptions): NewTask { let halt = () => { halt = () => {}; + routine.runLevel = 1; halted.resolve(); future.reject(new Error("halted")); }; @@ -92,8 +96,10 @@ export function createTask(options: TaskOptions): NewTask { let routine = createCoroutine({ scope, *operation() { + routine.runLevel = 1; halt = () => { halt = () => {}; + routine.runLevel = 2; state.current.halted = true; trap.outcome = Ok(Nothing()); routine.return(Ok()); @@ -102,6 +108,8 @@ export function createTask(options: TaskOptions): NewTask { let finalization = state.current.halted && !outcome.ok ? outcome : Ok(); + children.linked = false; + let destruction = yield* box(destroy); finalization = !destruction.ok ? destruction : finalization; @@ -200,6 +208,7 @@ interface TaskLink { } class TaskTree implements TaskLink { + linked = true; tasks = new Set>(); constructor(public crash: (error: Error) => void) {} add(task: Task) { @@ -211,10 +220,12 @@ class TaskTree implements TaskLink { finalization: Result, ) { this.tasks.delete(task); - if (!finalization.ok) { - this.crash(finalization.error); - } else if (!outcome.ok) { - this.crash(outcome.error); + if (this.linked) { + if (!finalization.ok) { + this.crash(finalization.error); + } else if (!outcome.ok) { + this.crash(outcome.error); + } } } diff --git a/lib/types.ts b/lib/types.ts index cd42ab537..796e8d25c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -355,7 +355,7 @@ export interface Effect { * @ignore */ export interface Coroutine { - version: number; + runLevel: 0 | 1 | 2; scope: Scope; data: { exit(resolve: Resolve>): void; diff --git a/test/each.test.ts b/test/each.test.ts index 2e8d92076..4a56990bc 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -113,15 +113,15 @@ function sequence(...values: string[]): Stream { *[Symbol.iterator]() { let items = values.slice(); return { - *next(): Operation> { - let value = items.shift(); - if (typeof value !== "undefined") { - return { done: false, value }; - } else { - return { done: true, value: undefined } - } - } - } - } - } + *next(): Operation> { + let value = items.shift(); + if (typeof value !== "undefined") { + return { done: false, value }; + } else { + return { done: true, value: undefined }; + } + }, + }; + }, + }; } diff --git a/test/run.test.ts b/test/run.test.ts index 8f264bd79..7db36a04d 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -1,7 +1,15 @@ // deno-lint-ignore-file no-unsafe-finally import { blowUp, createNumber, describe, expect, it } from "./suite.ts"; -import { action, run, sleep, spawn, suspend, type Task, until } from "../mod.ts"; +import { + action, + run, + sleep, + spawn, + suspend, + type Task, + until, +} from "../mod.ts"; describe("run()", () => { it("can run an operation", async () => { diff --git a/test/spawn.test.ts b/test/spawn.test.ts index fb75b2af6..07bfce1f5 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -86,10 +86,9 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it.skip("rejects when child errors during completing", async () => { - let child; + it("rejects when child errors during completing", async () => { let root = run(function* root() { - child = yield* spawn(function* child() { + yield* spawn(function* child() { try { yield* suspend(); } finally { @@ -100,11 +99,10 @@ describe("spawn", () => { return "foo"; }); - await expect(child).rejects.toMatchObject({ message: "moo" }); await expect(root).rejects.toHaveProperty("message", "moo"); }); - it.skip("rejects when child errors during halting", async () => { + it("rejects when child errors during halting", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -119,7 +117,7 @@ describe("spawn", () => { }); await expect(root.halt()).rejects.toHaveProperty("message", "moo"); - await expect(child).rejects.toHaveProperty("message", "moo"); + await expect(child!.halt()).rejects.toHaveProperty("message", "moo"); await expect(root.halt()).rejects.toHaveProperty("message", "moo"); }); @@ -175,7 +173,7 @@ describe("spawn", () => { ]); }); - it.skip("halts children on explicit halt", async () => { + it("halts children on explicit halt", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -191,7 +189,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it.skip("raises an uncatchable error if a spawned child fails", async () => { + it("raises an uncatchable error if a spawned child fails", async () => { let task = run(function* () { yield* spawn(function* () { yield* sleep(5); From 73fdbd9bfabf418a9936dfd4447d7352280e846c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 19 May 2025 09:28:46 -0500 Subject: [PATCH 026/119] Make sure `Do()` operations can actually raise errors --- lib/do.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/do.ts b/lib/do.ts index fe9da466f..4458fb3d6 100644 --- a/lib/do.ts +++ b/lib/do.ts @@ -32,6 +32,9 @@ export function Do(effect: Effect): Operation { return { done: false, value: perform }; } }, + throw(error) { + throw error; + } }; }, }; From 531cd65f1bed42db17ba60c87d4316d177665b53 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 22 May 2025 12:20:13 -0500 Subject: [PATCH 027/119] halt task on scope destroy, not the other way around --- lib/do.ts | 6 +++--- lib/task.ts | 4 ++-- test/each.test.ts | 2 +- test/scope.test.ts | 54 ++++++++++++++++++++++++++++------------------ 4 files changed, 39 insertions(+), 27 deletions(-) diff --git a/lib/do.ts b/lib/do.ts index 4458fb3d6..900f34c0f 100644 --- a/lib/do.ts +++ b/lib/do.ts @@ -32,9 +32,9 @@ export function Do(effect: Effect): Operation { return { done: false, value: perform }; } }, - throw(error) { - throw error; - } + throw(error) { + throw error; + }, }; }, }; diff --git a/lib/task.ts b/lib/task.ts index 04714283b..c8af575fa 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -72,7 +72,7 @@ export function createTask(options: TaskOptions): NewTask { routine.return(Ok()); }); scope.set(TaskLinkContext, children); - scope.ensure(() => children.destroy()); + scope.ensure(() => task.halt()); let state: { current: TaskState } = { current: { status: "pending", halted: false }, @@ -110,7 +110,7 @@ export function createTask(options: TaskOptions): NewTask { children.linked = false; - let destruction = yield* box(destroy); + let destruction = yield* box(() => children.destroy()); finalization = !destruction.ok ? destruction : finalization; diff --git a/test/each.test.ts b/test/each.test.ts index 4a56990bc..052276b91 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -26,7 +26,7 @@ describe("each", () => { }); }); - it("can be used to iterate nested streams", async () => { + it.skip("can be used to iterate nested streams", async () => { await run(function* () { let actual = [] as string[]; let outer = sequence("one", "two"); diff --git a/test/scope.test.ts b/test/scope.test.ts index 42fdf9f3c..515c68530 100644 --- a/test/scope.test.ts +++ b/test/scope.test.ts @@ -38,19 +38,34 @@ describe("Scope", () => { await expect(close()).resolves.toBeUndefined(); }); + it("halts tasks that it contains when it is destroyed", async () => { + let [scope, destroy] = createScope(); + let halted = false; + scope.run(function* () { + try { + yield* suspend(); + } finally { + halted = true; + } + }); + + await expect(destroy()).resolves.toBeUndefined(); + expect(halted).toEqual(true); + }); + it("errors on close if there is an problem in teardown", async () => { let error = new Error("boom!"); let [scope, close] = createScope(); - run(() => - scope.spawn(function* () { - try { - yield* suspend(); - } finally { - // deno-lint-ignore no-unsafe-finally - throw error; - } - }) - ); + + scope.run(function* () { + try { + yield* suspend(); + } finally { + // deno-lint-ignore no-unsafe-finally + throw error; + } + }); + await expect(close()).rejects.toEqual(error); }); @@ -59,18 +74,15 @@ describe("Scope", () => { let [scope, close] = createScope(); let tester: Tester = {}; - run(() => - scope.spawn(function* () { - yield* useTester(tester); - yield* suspend(); - }) - ); + scope.run(function* () { + yield* useTester(tester); + yield* suspend(); + }); + + scope.run(function* () { + throw error; + }); - run(() => - scope.spawn(function* () { - throw error; - }) - ); await expect(close()).resolves.toBeUndefined(); expect(tester.status).toEqual("closed"); }); From b2f5ead818b8fe3e0117a47d6e5f62c33e1d2ac6 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 22 May 2025 12:36:51 -0500 Subject: [PATCH 028/119] each test is working, race tests are deadlocked --- test/each.test.ts | 4 ++-- test/race.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/each.test.ts b/test/each.test.ts index 052276b91..9a76188d2 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -26,11 +26,11 @@ describe("each", () => { }); }); - it.skip("can be used to iterate nested streams", async () => { + it("can be used to iterate nested streams", async () => { await run(function* () { let actual = [] as string[]; let outer = sequence("one", "two"); - let inner = sequence("three"); + let inner = sequence("three", "four", "five"); for (let value of yield* each(outer)) { actual.push(value); diff --git a/test/race.test.ts b/test/race.test.ts index 07486eade..d1ae33d72 100644 --- a/test/race.test.ts +++ b/test/race.test.ts @@ -44,7 +44,7 @@ describe("race()", () => { await expect(result).rejects.toHaveProperty("message", "boom: bar"); }); - it("resolves when one of the given operations resolves synchronously first", async () => { + it.skip("resolves when one of the given operations resolves synchronously first", async () => { let result = run(() => race([ syncResolve("foo"), @@ -56,7 +56,7 @@ describe("race()", () => { await expect(result).resolves.toEqual("foo"); }); - it("rejects when one of the given operations rejects synchronously first", async () => { + it.skip("rejects when one of the given operations rejects synchronously first", async () => { let result = run(() => race([ syncReject("foo"), From a8c9c4035b6b004196d006ffcaf3b9f88788a370 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 22 May 2025 13:07:26 -0500 Subject: [PATCH 029/119] take a stab at trap() --- lib/task.ts | 49 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index c8af575fa..03ca7397d 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -38,6 +38,8 @@ interface Trap { outcome: Result>; } +const TrapContext = createContext>("@effection/trap"); + function* trapset( trap: Trap, op: () => Operation, @@ -62,12 +64,41 @@ function* trapset( } } +export function* trap(op: () => Operation): Operation { + let original = yield* TrapContext.expect(); + let trap: Trap = { outcome: Ok(Nothing()) }; + yield* TrapContext.set(trap); + try { + let outcome = yield* trapset(trap, op); + if (!outcome.ok) { + throw outcome.error; + } else { + if (outcome.value.exists) { + return outcome.value.value; + } else { + original.outcome = outcome; + yield { + description: "propagate halt", + enter(_, routine) { + routine.return(Ok()); + return (didExit) => didExit(Ok()); + }, + }; + throw `this never happens`; + } + } + } finally { + yield* TrapContext.set(original); + } +} + export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; - let [scope, destroy] = createScopeInternal(owner); + let [scope] = createScopeInternal(owner); let link = owner.expect(TaskLinkContext); let children = new TaskTree((error) => { + let trap = scope.expect(TrapContext); trap.outcome = Err(error); routine.return(Ok()); }); @@ -89,9 +120,13 @@ export function createTask(options: TaskOptions): NewTask { future.reject(new Error("halted")); }; - let trap: Trap = { + // let trap: Trap = { + // outcome: Ok(Nothing()), + // }; + + scope.set(TrapContext, { outcome: Ok(Nothing()), - }; + }); let routine = createCoroutine({ scope, @@ -101,9 +136,13 @@ export function createTask(options: TaskOptions): NewTask { halt = () => {}; routine.runLevel = 2; state.current.halted = true; + let trap = scope.expect(TrapContext); trap.outcome = Ok(Nothing()); routine.return(Ok()); }; + + let trap = scope.expect(TrapContext) as Trap; + let outcome = yield* trapset(trap, operation); let finalization = state.current.halted && !outcome.ok ? outcome : Ok(); @@ -250,10 +289,6 @@ class TaskTree implements TaskLink { } } -export function trap(op: () => Operation): Operation { - return op(); -} - export function encapsulate(op: () => Operation): Operation { return op(); } From da1cf57897a11fa03e93af76e6c006880a496576 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 22 May 2025 16:00:51 -0500 Subject: [PATCH 030/119] fix incorrect test --- test/resource.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/resource.test.ts b/test/resource.test.ts index 54731712a..7a81e7973 100644 --- a/test/resource.test.ts +++ b/test/resource.test.ts @@ -52,13 +52,13 @@ describe("resource", () => { let task = run(function* () { yield* resource(function* (provide) { yield* spawn(function* () { - yield* sleep(5); + yield* sleep(1); throw new Error("moo"); }); yield* provide(); }); try { - yield* sleep(10); + yield* sleep(5); } catch (error) { return error; } @@ -82,7 +82,7 @@ describe("resource", () => { await task.halt(); - expect(state.status).toEqual("pending"); + expect(state.status).toEqual("finalized"); }); }); @@ -93,8 +93,6 @@ function createResource(container: State): Operation { container.status = "active"; }); - yield* sleep(1); - try { yield* provide(container); } finally { From 1b7efdebc75cb69a6e972459869b18d198851965 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 22 May 2025 22:26:07 -0500 Subject: [PATCH 031/119] skip deadlocking tests --- test/all.test.ts | 2 +- test/each.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/all.test.ts b/test/all.test.ts index dade99f81..b908af21c 100644 --- a/test/all.test.ts +++ b/test/all.test.ts @@ -62,7 +62,7 @@ describe("all()", () => { await expect(result).resolves.toEqual(["foo", "bar", "baz"]); }); - it("rejects when one of the given operations rejects synchronously first", async () => { + it.skip("rejects when one of the given operations rejects synchronously first", async () => { let result = run(() => all([ syncResolve("foo"), diff --git a/test/each.test.ts b/test/each.test.ts index 9a76188d2..1975a3a9b 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -9,7 +9,7 @@ import { } from "../mod.ts"; describe("each", () => { - it("can be used to iterate a stream", async () => { + it.skip("can be used to iterate a stream", async () => { await run(function* () { let actual = [] as string[]; let channel = sequence("one", "two", "three"); @@ -26,7 +26,7 @@ describe("each", () => { }); }); - it("can be used to iterate nested streams", async () => { + it.skip("can be used to iterate nested streams", async () => { await run(function* () { let actual = [] as string[]; let outer = sequence("one", "two"); @@ -54,7 +54,7 @@ describe("each", () => { }); }); - it("handles context correctly if you break out of a loop", async () => { + it.skip("handles context correctly if you break out of a loop", async () => { await expect(run(function* () { let seq = sequence("hello world"); From 79b9247d60f60f6faea83cb25722b3408009f373 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 May 2025 16:31:43 -0500 Subject: [PATCH 032/119] fixup trap --- lib/task.ts | 22 ++++++++++++---------- test/each.test.ts | 6 +++--- test/resource.test.ts | 4 +++- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 03ca7397d..4c1a621c7 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-unsafe-finally import { createCoroutine } from "./coroutine.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; import type { Coroutine, Operation, Scope, Task } from "./types.ts"; @@ -53,7 +54,6 @@ function* trapset( } catch (error) { trap.outcome = Err(error as Error); } finally { - // deno-lint-ignore no-unsafe-finally return (yield { description: "trapset return", enter(resolve) { @@ -65,30 +65,32 @@ function* trapset( } export function* trap(op: () => Operation): Operation { + //return yield* op(); let original = yield* TrapContext.expect(); let trap: Trap = { outcome: Ok(Nothing()) }; yield* TrapContext.set(trap); try { - let outcome = yield* trapset(trap, op); + yield* trapset(trap, op); + } finally { + yield* TrapContext.set(original); + const { outcome } = trap; if (!outcome.ok) { throw outcome.error; } else { - if (outcome.value.exists) { - return outcome.value.value; + const { value } = outcome; + if (value.exists) { + return value.value; } else { - original.outcome = outcome; - yield { + return (yield { description: "propagate halt", enter(_, routine) { + original.outcome = Ok(Nothing()); routine.return(Ok()); return (didExit) => didExit(Ok()); }, - }; - throw `this never happens`; + }) as T; } } - } finally { - yield* TrapContext.set(original); } } diff --git a/test/each.test.ts b/test/each.test.ts index 1975a3a9b..9a76188d2 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -9,7 +9,7 @@ import { } from "../mod.ts"; describe("each", () => { - it.skip("can be used to iterate a stream", async () => { + it("can be used to iterate a stream", async () => { await run(function* () { let actual = [] as string[]; let channel = sequence("one", "two", "three"); @@ -26,7 +26,7 @@ describe("each", () => { }); }); - it.skip("can be used to iterate nested streams", async () => { + it("can be used to iterate nested streams", async () => { await run(function* () { let actual = [] as string[]; let outer = sequence("one", "two"); @@ -54,7 +54,7 @@ describe("each", () => { }); }); - it.skip("handles context correctly if you break out of a loop", async () => { + it("handles context correctly if you break out of a loop", async () => { await expect(run(function* () { let seq = sequence("hello world"); diff --git a/test/resource.test.ts b/test/resource.test.ts index 7a81e7973..3d6d8d22f 100644 --- a/test/resource.test.ts +++ b/test/resource.test.ts @@ -82,7 +82,7 @@ describe("resource", () => { await task.halt(); - expect(state.status).toEqual("finalized"); + expect(state.status).toEqual("pending"); }); }); @@ -93,6 +93,8 @@ function createResource(container: State): Operation { container.status = "active"; }); + yield* sleep(0); + try { yield* provide(container); } finally { From 433263763a5269da4d944a6a79d9b5ed3d0f0ddc Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 May 2025 17:10:52 -0500 Subject: [PATCH 033/119] first ask if a computation produced a result only then may you ask if it was an error or a value --- lib/task.ts | 94 ++++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 4c1a621c7..60121e0bb 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -36,7 +36,7 @@ export type TaskState = { }; interface Trap { - outcome: Result>; + outcome: Maybe>; } const TrapContext = createContext>("@effection/trap"); @@ -44,15 +44,15 @@ const TrapContext = createContext>("@effection/trap"); function* trapset( trap: Trap, op: () => Operation, -): Operation>> { +): Operation>> { let original = trap.outcome; try { let value = yield* op(); if (trap.outcome === original) { - trap.outcome = Ok(Just(value)); + trap.outcome = Just(Ok(value)); } } catch (error) { - trap.outcome = Err(error as Error); + trap.outcome = Just(Err(error as Error)); } finally { return (yield { description: "trapset return", @@ -65,33 +65,33 @@ function* trapset( } export function* trap(op: () => Operation): Operation { - //return yield* op(); - let original = yield* TrapContext.expect(); - let trap: Trap = { outcome: Ok(Nothing()) }; - yield* TrapContext.set(trap); - try { - yield* trapset(trap, op); - } finally { - yield* TrapContext.set(original); - const { outcome } = trap; - if (!outcome.ok) { - throw outcome.error; - } else { - const { value } = outcome; - if (value.exists) { - return value.value; - } else { - return (yield { - description: "propagate halt", - enter(_, routine) { - original.outcome = Ok(Nothing()); - routine.return(Ok()); - return (didExit) => didExit(Ok()); - }, - }) as T; - } - } - } + return yield* op(); + // let original = yield* TrapContext.expect(); + // let trap: Trap = { outcome: Ok(Nothing()) }; + // yield* TrapContext.set(trap); + // try { + // yield* trapset(trap, op); + // } finally { + // yield* TrapContext.set(original); + // const { outcome } = trap; + // if (!outcome.ok) { + // throw outcome.error; + // } else { + // const { value } = outcome; + // if (value.exists) { + // return value.value; + // } else { + // return (yield { + // description: "propagate halt", + // enter(_, routine) { + // original.outcome = Ok(Nothing()); + // routine.return(Ok()); + // return (didExit) => didExit(Ok()); + // }, + // }) as T; + // } + // } + // } } export function createTask(options: TaskOptions): NewTask { @@ -101,7 +101,7 @@ export function createTask(options: TaskOptions): NewTask { let link = owner.expect(TaskLinkContext); let children = new TaskTree((error) => { let trap = scope.expect(TrapContext); - trap.outcome = Err(error); + trap.outcome = Just(Err(error)); routine.return(Ok()); }); scope.set(TaskLinkContext, children); @@ -127,7 +127,7 @@ export function createTask(options: TaskOptions): NewTask { // }; scope.set(TrapContext, { - outcome: Ok(Nothing()), + outcome: Nothing>(), }); let routine = createCoroutine({ @@ -139,7 +139,7 @@ export function createTask(options: TaskOptions): NewTask { routine.runLevel = 2; state.current.halted = true; let trap = scope.expect(TrapContext); - trap.outcome = Ok(Nothing()); + trap.outcome = Nothing(); routine.return(Ok()); }; @@ -147,7 +147,10 @@ export function createTask(options: TaskOptions): NewTask { let outcome = yield* trapset(trap, operation); - let finalization = state.current.halted && !outcome.ok ? outcome : Ok(); + let finalization = + state.current.halted && outcome.exists && !outcome.value.ok + ? outcome.value + : Ok(); children.linked = false; @@ -161,14 +164,15 @@ export function createTask(options: TaskOptions): NewTask { halted.resolve(); if (!finalization.ok) { future.reject(finalization.error); - } else if (!outcome.ok) { - future.reject(outcome.error); - } else { - if (outcome.value.exists) { - future.resolve(outcome.value.value); + } else if (outcome.exists) { + const { value: result } = outcome; + if (result.ok) { + future.resolve(result.value); } else { - future.reject(new Error("halted")); + future.reject(result.error); } + } else { + future.reject(new Error("halted")); } } else { future.reject(new Error("halted")); @@ -243,7 +247,7 @@ interface TaskLink { add(task: Task): void; finalized( task: Task, - outcome: Result>, + outcome: Maybe>, finalization: Result, ): void; } @@ -257,15 +261,15 @@ class TaskTree implements TaskLink { } finalized( task: Task, - outcome: Result>, + outcome: Maybe>, finalization: Result, ) { this.tasks.delete(task); if (this.linked) { if (!finalization.ok) { this.crash(finalization.error); - } else if (!outcome.ok) { - this.crash(outcome.error); + } else if (outcome.exists && !outcome.value.ok) { + this.crash(outcome.value.error); } } } From 24f5a4d37a7656c951f01fe59a2b1ef09ed2728e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 May 2025 17:15:01 -0500 Subject: [PATCH 034/119] implement userland trap in terms of existence of outcome first --- lib/task.ts | 53 ++++++++++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 60121e0bb..f7789b46c 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -65,33 +65,32 @@ function* trapset( } export function* trap(op: () => Operation): Operation { - return yield* op(); - // let original = yield* TrapContext.expect(); - // let trap: Trap = { outcome: Ok(Nothing()) }; - // yield* TrapContext.set(trap); - // try { - // yield* trapset(trap, op); - // } finally { - // yield* TrapContext.set(original); - // const { outcome } = trap; - // if (!outcome.ok) { - // throw outcome.error; - // } else { - // const { value } = outcome; - // if (value.exists) { - // return value.value; - // } else { - // return (yield { - // description: "propagate halt", - // enter(_, routine) { - // original.outcome = Ok(Nothing()); - // routine.return(Ok()); - // return (didExit) => didExit(Ok()); - // }, - // }) as T; - // } - // } - // } + let original = yield* TrapContext.expect(); + let trap: Trap = { outcome: Nothing>() }; + yield* TrapContext.set(trap); + try { + yield* trapset(trap, op); + } finally { + yield* TrapContext.set(original); + const { outcome } = trap; + if (outcome.exists) { + const { value: result } = outcome; + if (result.ok) { + return result.value; + } else { + throw result.error; + } + } else { + return (yield { + description: "propagate halt", + enter(_, routine) { + original.outcome = Nothing(); + routine.return(Ok()); + return (didExit) => didExit(Ok()); + }, + }) as T; + } + } } export function createTask(options: TaskOptions): NewTask { From d1457d6e76a3db7ab88f430d6d17eb31740506ea Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 May 2025 17:55:21 -0500 Subject: [PATCH 035/119] checkpoint --- lib/task.ts | 9 ++++----- test/scoped.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index f7789b46c..c6a63c0bd 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -57,6 +57,7 @@ function* trapset( return (yield { description: "trapset return", enter(resolve) { + //console.log({ trap }); resolve(Ok(trap.outcome)); return (didExit) => didExit(Ok()); }, @@ -84,7 +85,7 @@ export function* trap(op: () => Operation): Operation { return (yield { description: "propagate halt", enter(_, routine) { - original.outcome = Nothing(); + original.outcome = Nothing(); routine.return(Ok()); return (didExit) => didExit(Ok()); }, @@ -101,6 +102,7 @@ export function createTask(options: TaskOptions): NewTask { let children = new TaskTree((error) => { let trap = scope.expect(TrapContext); trap.outcome = Just(Err(error)); + // console.log({ crash: trap }); routine.return(Ok()); }); scope.set(TaskLinkContext, children); @@ -121,10 +123,6 @@ export function createTask(options: TaskOptions): NewTask { future.reject(new Error("halted")); }; - // let trap: Trap = { - // outcome: Ok(Nothing()), - // }; - scope.set(TrapContext, { outcome: Nothing>(), }); @@ -263,6 +261,7 @@ class TaskTree implements TaskLink { outcome: Maybe>, finalization: Result, ) { + // console.log({ finalized: { linked: this.linked, outcome, finalization }}); this.tasks.delete(task); if (this.linked) { if (!finalization.ok) { diff --git a/test/scoped.test.ts b/test/scoped.test.ts index 88a03c6fb..2e9a9cd52 100644 --- a/test/scoped.test.ts +++ b/test/scoped.test.ts @@ -57,8 +57,8 @@ describe("scoped", () => { } })); - it("delimits error boundaries", () => - run(function* () { + it("delimits error boundaries", async () => + await run(function* () { try { yield* scoped(function* () { yield* spawn(function* () { From 5ad86bf0c8bb89d51a516091a98def926b7a023c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 May 2025 18:29:12 -0500 Subject: [PATCH 036/119] When crashing, lookup scope from the routine --- lib/task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/task.ts b/lib/task.ts index c6a63c0bd..a306bd0d5 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -100,7 +100,7 @@ export function createTask(options: TaskOptions): NewTask { let link = owner.expect(TaskLinkContext); let children = new TaskTree((error) => { - let trap = scope.expect(TrapContext); + let trap = routine.scope.expect(TrapContext); trap.outcome = Just(Err(error)); // console.log({ crash: trap }); routine.return(Ok()); From c21389d07d5a7c5e11b2842b44a8a017d5e36e7b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 May 2025 18:32:09 -0500 Subject: [PATCH 037/119] unroll trap + cleanup --- lib/task.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index a306bd0d5..f23153ca8 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -57,7 +57,6 @@ function* trapset( return (yield { description: "trapset return", enter(resolve) { - //console.log({ trap }); resolve(Ok(trap.outcome)); return (didExit) => didExit(Ok()); }, @@ -68,9 +67,12 @@ function* trapset( export function* trap(op: () => Operation): Operation { let original = yield* TrapContext.expect(); let trap: Trap = { outcome: Nothing>() }; - yield* TrapContext.set(trap); try { - yield* trapset(trap, op); + yield* TrapContext.set(trap); + let value = yield* op(); + trap.outcome = Just(Ok(value)); + } catch (error) { + trap.outcome = Just(Err(error as Error)); } finally { yield* TrapContext.set(original); const { outcome } = trap; @@ -102,7 +104,6 @@ export function createTask(options: TaskOptions): NewTask { let children = new TaskTree((error) => { let trap = routine.scope.expect(TrapContext); trap.outcome = Just(Err(error)); - // console.log({ crash: trap }); routine.return(Ok()); }); scope.set(TaskLinkContext, children); @@ -261,7 +262,6 @@ class TaskTree implements TaskLink { outcome: Maybe>, finalization: Result, ) { - // console.log({ finalized: { linked: this.linked, outcome, finalization }}); this.tasks.delete(task); if (this.linked) { if (!finalization.ok) { From 2f057803f5660b79862fd4f0ccb89c9de49582ac Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Jun 2025 20:37:52 +0300 Subject: [PATCH 038/119] add test case for catching a crash at the appropriate time. --- test/scoped.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/scoped.test.ts b/test/scoped.test.ts index 2e9a9cd52..ff9853a09 100644 --- a/test/scoped.test.ts +++ b/test/scoped.test.ts @@ -155,4 +155,25 @@ describe("scoped", () => { } })); }); + + it.skip("throws errors at the correct point when there are multiple nested scopes", async () =>{ + let task = run(function* () { + return yield* scoped(function* () { + yield* spawn(function* () { + yield* sleep(1); + throw new Error("boom!"); + }); + + try { + return yield* scoped(function* () { + yield* suspend(); + }); + } catch (error) { + return error; + } + }); + }); + + await expect(task).rejects.toMatchObject({ message: "boom!" }); + }); }); From 3cc79df756fdead560be4c7db5fe319a0f104075 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 9 Jun 2025 12:55:36 +0300 Subject: [PATCH 039/119] try to simplify --- lib/scope-internal.ts | 28 ++- lib/task.ts | 555 +++++++++++++++++++++++++----------------- test/run.test.ts | 23 +- test/scoped.test.ts | 2 +- 4 files changed, 357 insertions(+), 251 deletions(-) diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index 1fe2be493..d243cdc43 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -2,6 +2,7 @@ import { Children, Generation } from "./contexts.ts"; import { Err, Ok, unbox } from "./result.ts"; import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; +import { WithResolvers, withResolvers } from "./with-resolvers.ts"; export function createScopeInternal( parent?: Scope, @@ -62,18 +63,33 @@ export function createScopeInternal( let unbind = parent ? (parent as ScopeInternal).ensure(destroy) : () => {}; + let destruction: WithResolvers | undefined = undefined; + function* destroy(): Operation { + if (destruction) { + return yield* destruction.operation; + } + destruction = withResolvers(); parent?.expect(Children).delete(scope); unbind(); let outcome = Ok(); - for (let destructor of [...destructors].reverse()) { - try { - destructors.delete(destructor); - yield* destructor(); - } catch (error) { - outcome = Err(error as Error); + try { + for (let destructor of [...destructors].reverse()) { + try { + destructors.delete(destructor); + yield* destructor(); + } catch (error) { + outcome = Err(error as Error); + } + } + } finally { + if (outcome.ok) { + destruction.resolve(); + } else { + destruction.reject(outcome.error); } } + unbox(outcome); } diff --git a/lib/task.ts b/lib/task.ts index f23153ca8..06ba295ac 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,12 +1,11 @@ -// deno-lint-ignore-file no-unsafe-finally -import { createCoroutine } from "./coroutine.ts"; -import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; -import type { Coroutine, Operation, Scope, Task } from "./types.ts"; import { box } from "./box.ts"; -import { Err, Ok, type Result } from "./result.ts"; -import { createFuture } from "./future.ts"; import { createContext } from "./context.ts"; +import { createCoroutine } from "./coroutine.ts"; +import { createFuture } from "./future.ts"; import { Just, type Maybe, Nothing } from "./maybe.ts"; +import { Err, Ok, type Result } from "./result.ts"; +import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; +import type { Coroutine, Operation, Scope, Task } from "./types.ts"; export interface TaskOptions { owner: ScopeInternal; @@ -20,168 +19,30 @@ export interface NewTask { start(): void; } -export type TaskState = { - status: "pending"; - halted: boolean; -} | { - status: "finalizing"; - halted: boolean; - computation: Result; - finalization: Result; -} | { - status: "finalized"; - halted: boolean; - computation: Result; - finalization: Result; -}; - -interface Trap { - outcome: Maybe>; -} - -const TrapContext = createContext>("@effection/trap"); - -function* trapset( - trap: Trap, - op: () => Operation, -): Operation>> { - let original = trap.outcome; - try { - let value = yield* op(); - if (trap.outcome === original) { - trap.outcome = Just(Ok(value)); - } - } catch (error) { - trap.outcome = Just(Err(error as Error)); - } finally { - return (yield { - description: "trapset return", - enter(resolve) { - resolve(Ok(trap.outcome)); - return (didExit) => didExit(Ok()); - }, - }) as typeof trap.outcome; - } -} - -export function* trap(op: () => Operation): Operation { - let original = yield* TrapContext.expect(); - let trap: Trap = { outcome: Nothing>() }; - try { - yield* TrapContext.set(trap); - let value = yield* op(); - trap.outcome = Just(Ok(value)); - } catch (error) { - trap.outcome = Just(Err(error as Error)); - } finally { - yield* TrapContext.set(original); - const { outcome } = trap; - if (outcome.exists) { - const { value: result } = outcome; - if (result.ok) { - return result.value; - } else { - throw result.error; - } - } else { - return (yield { - description: "propagate halt", - enter(_, routine) { - original.outcome = Nothing(); - routine.return(Ok()); - return (didExit) => didExit(Ok()); - }, - }) as T; - } - } -} - export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; - let [scope] = createScopeInternal(owner); - - let link = owner.expect(TaskLinkContext); - let children = new TaskTree((error) => { - let trap = routine.scope.expect(TrapContext); - trap.outcome = Just(Err(error)); - routine.return(Ok()); - }); - scope.set(TaskLinkContext, children); - scope.ensure(() => task.halt()); - - let state: { current: TaskState } = { - current: { status: "pending", halted: false }, - }; + let [scope, destroy] = createScopeInternal(owner); let future = createFuture(); + let finalized = createFuture< + { outcome: Maybe>; teardown: Result } + >(); - let halted = createFuture(); - - let halt = () => { - halt = () => {}; - routine.runLevel = 1; - halted.resolve(); - future.reject(new Error("halted")); - }; - - scope.set(TrapContext, { - outcome: Nothing>(), + scope.ensure(function* teardown(): Operation { + routine.scope.expect(TrapContext).outcome = Nothing(); + routine.return(Ok()); }); - let routine = createCoroutine({ - scope, - *operation() { - routine.runLevel = 1; - halt = () => { - halt = () => {}; - routine.runLevel = 2; - state.current.halted = true; - let trap = scope.expect(TrapContext); - trap.outcome = Nothing(); - routine.return(Ok()); - }; - - let trap = scope.expect(TrapContext) as Trap; - - let outcome = yield* trapset(trap, operation); - - let finalization = - state.current.halted && outcome.exists && !outcome.value.ok - ? outcome.value - : Ok(); - - children.linked = false; - - let destruction = yield* box(() => children.destroy()); - - finalization = !destruction.ok ? destruction : finalization; - - link.finalized(task, outcome, finalization); - - if (!state.current.halted) { - halted.resolve(); - if (!finalization.ok) { - future.reject(finalization.error); - } else if (outcome.exists) { - const { value: result } = outcome; - if (result.ok) { - future.resolve(result.value); - } else { - future.reject(result.error); - } - } else { - future.reject(new Error("halted")); - } - } else { - future.reject(new Error("halted")); - if (finalization.ok) { - halted.resolve(); - } else { - halted.reject(finalization.error); - } - } - }, - }); + function* halt() { + yield* destroy(); + let { outcome, teardown } = yield* finalized.future; + if (!teardown.ok) { + throw teardown.error; + } + if (outcome.exists && !outcome.value.ok) { + throw outcome.value.error; + } + } let task = Object.defineProperties(future.future, { halt: { @@ -190,30 +51,24 @@ export function createTask(options: TaskOptions): NewTask { return Object.defineProperties(Object.create(Promise.prototype), { [Symbol.iterator]: { enumerable: false, - *value() { - halt(); - yield* halted.future; - }, + value: halt, }, then: { enumerable: false, - value(...args: Parameters) { - halt(); - return halted.future.then(...args); + value(...args: Parameters["then"]>) { + return owner.run(halt).then(...args); }, }, catch: { enumerable: false, - value(...args: Parameters) { - halt(); - return halted.future.catch(...args); + value(...args: Parameters["catch"]>) { + return owner.run(halt).catch(...args); }, }, finally: { enumerable: false, - value(...args: Parameters) { - halt(); - return halted.future.finally(...args); + value(...args: Parameters["finally"]>) { + return owner.run(halt).finally(...args); }, }, }); @@ -229,74 +84,324 @@ export function createTask(options: TaskOptions): NewTask { }, }) as Task; - link.add(task); + scope.set(TrapContext, { outcome: Nothing>() }); + + let routine = createCoroutine({ + scope, + *operation() { + routine.runLevel = 1; + + let outcome = yield* trapsafe(operation); + + routine.runLevel = 2; + + let teardown = yield* box(destroy); + finalized.resolve({ outcome, teardown }); + + if (!teardown.ok) { + future.reject(teardown.error); + } else { + if (outcome.exists) { + let result = outcome.value; + if (result.ok) { + future.resolve(result.value); + } else { + future.reject(result.error); + } + } else { + future.reject(new Error("halted")); + } + } + }, + }); let start = () => routine.next(Ok()); return { task, scope, routine, start }; } -const TaskLinkContext = createContext("@effection/tasks", { - add() {}, - finalized() {}, -}); - -interface TaskLink { - add(task: Task): void; - finalized( - task: Task, - outcome: Maybe>, - finalization: Result, - ): void; +// type TaskState = { +// status: "pending"; +// halted: boolean; +// } | { +// status: "finalizing"; +// halted: boolean; +// computation: Result; +// finalization: Result; +// } | { +// status: "finalized"; +// halted: boolean; +// computation: Result; +// finalization: Result; +// }; + +interface Trap { + outcome: Maybe>; } -class TaskTree implements TaskLink { - linked = true; - tasks = new Set>(); - constructor(public crash: (error: Error) => void) {} - add(task: Task) { - this.tasks.add(task); - } - finalized( - task: Task, - outcome: Maybe>, - finalization: Result, - ) { - this.tasks.delete(task); - if (this.linked) { - if (!finalization.ok) { - this.crash(finalization.error); - } else if (outcome.exists && !outcome.value.ok) { - this.crash(outcome.value.error); - } - } - } +const TrapContext = createContext>("@effection/trap"); - *destroy() { - let result = Ok(); - while (this.tasks.size > 0) { - let tasks = [...this.tasks].reverse(); - for (let task of tasks) { - this.tasks.delete(task); - } - for (let task of tasks) { - try { - yield* task.halt(); - } catch (error) { - result = Err(error as Error); - } - } - } - if (!result.ok) { - throw result.error; +function* trapsafe( + op: () => Operation, +): Operation>> { + let trap = yield* TrapContext.expect(); + let original = trap.outcome; + try { + let value = yield* op(); + if (trap.outcome === original) { + trap.outcome = Just(Ok(value)); } + } catch (error) { + trap.outcome = Just(Err(error as Error)); + } finally { + // deno-lint-ignore no-unsafe-finally + return (yield { + description: "trapset return", + enter(resolve) { + resolve(Ok(trap.outcome)); + return (didExit) => didExit(Ok()); + }, + }) as Maybe>; } } +// export function* trap(op: () => Operation): Operation { +// let original = yield* TrapContext.expect(); +// let trap: Trap = { outcome: Nothing>() }; +// try { +// yield* TrapContext.set(trap); +// let value = yield* op(); +// trap.outcome = Just(Ok(value)); +// } catch (error) { +// trap.outcome = Just(Err(error as Error)); +// } finally { +// yield* TrapContext.set(original); +// const { outcome } = trap; +// if (outcome.exists) { +// const { value: result } = outcome; +// if (result.ok) { +// return result.value; +// } else { +// throw result.error; +// } +// } else { +// return (yield { +// description: "propagate halt", +// enter(_, routine) { +// original.outcome = Nothing(); +// routine.return(Ok()); +// return (didExit) => didExit(Ok()); +// }, +// }) as T; +// } +// } +// } + +// export function createTask(options: TaskOptions): NewTask { +// let { owner, operation } = options; +// let [scope] = createScopeInternal(owner); + +// let link = owner.expect(TaskLinkContext); +// let children = new TaskTree((error) => { +// let trap = routine.scope.expect(TrapContext); +// trap.outcome = Just(Err(error)); +// routine.return(Ok()); +// }); +// scope.set(TaskLinkContext, children); +// scope.ensure(() => task.halt()); + +// let state: { current: TaskState } = { +// current: { status: "pending", halted: false }, +// }; + +// let future = createFuture(); + +// let halted = createFuture(); + +// let halt = () => { +// halt = () => {}; +// routine.runLevel = 1; +// halted.resolve(); +// future.reject(new Error("halted")); +// }; + +// scope.set(TrapContext, { +// outcome: Nothing>(), +// }); + +// let routine = createCoroutine({ +// scope, +// *operation() { +// routine.runLevel = 1; +// halt = () => { +// halt = () => {}; +// routine.runLevel = 2; +// state.current.halted = true; +// let trap = scope.expect(TrapContext); +// trap.outcome = Nothing(); +// routine.return(Ok()); +// }; + +// let trap = scope.expect(TrapContext) as Trap; + +// let outcome = yield* trapset(trap, operation); + +// let finalization = +// state.current.halted && outcome.exists && !outcome.value.ok +// ? outcome.value +// : Ok(); + +// children.linked = false; + +// let destruction = yield* box(() => children.destroy()); + +// finalization = !destruction.ok ? destruction : finalization; + +// link.finalized(task, outcome, finalization); + +// if (!state.current.halted) { +// halted.resolve(); +// if (!finalization.ok) { +// future.reject(finalization.error); +// } else if (outcome.exists) { +// const { value: result } = outcome; +// if (result.ok) { +// future.resolve(result.value); +// } else { +// future.reject(result.error); +// } +// } else { +// future.reject(new Error("halted")); +// } +// } else { +// future.reject(new Error("halted")); +// if (finalization.ok) { +// halted.resolve(); +// } else { +// halted.reject(finalization.error); +// } +// } +// }, +// }); + +// let task = Object.defineProperties(future.future, { +// halt: { +// enumerable: false, +// value() { +// return Object.defineProperties(Object.create(Promise.prototype), { +// [Symbol.iterator]: { +// enumerable: false, +// *value() { +// halt(); +// yield* halted.future; +// }, +// }, +// then: { +// enumerable: false, +// value(...args: Parameters) { +// halt(); +// return halted.future.then(...args); +// }, +// }, +// catch: { +// enumerable: false, +// value(...args: Parameters) { +// halt(); +// return halted.future.catch(...args); +// }, +// }, +// finally: { +// enumerable: false, +// value(...args: Parameters) { +// halt(); +// return halted.future.finally(...args); +// }, +// }, +// }); +// }, +// }, +// [Symbol.iterator]: { +// enumerable: false, +// value: future.future[Symbol.iterator], +// }, +// [Symbol.toStringTag]: { +// enumerable: false, +// value: "Task", +// }, +// }) as Task; + +// link.add(task); + +// let start = () => routine.next(Ok()); + +// return { task, scope, routine, start }; +// } + +// const TaskLinkContext = createContext("@effection/tasks", { +// add() {}, +// finalized() {}, +// }); + +// interface TaskLink { +// add(task: Task): void; +// finalized( +// task: Task, +// outcome: Maybe>, +// finalization: Result, +// ): void; +// } + +// class TaskTree implements TaskLink { +// linked = true; +// tasks = new Set>(); +// constructor(public crash: (error: Error) => void) {} +// add(task: Task) { +// this.tasks.add(task); +// } +// finalized( +// task: Task, +// outcome: Maybe>, +// finalization: Result, +// ) { +// this.tasks.delete(task); +// if (this.linked) { +// if (!finalization.ok) { +// this.crash(finalization.error); +// } else if (outcome.exists && !outcome.value.ok) { +// this.crash(outcome.value.error); +// } +// } +// } + +// *destroy() { +// let result = Ok(); +// while (this.tasks.size > 0) { +// let tasks = [...this.tasks].reverse(); +// for (let task of tasks) { +// this.tasks.delete(task); +// } +// for (let task of tasks) { +// try { +// yield* task.halt(); +// } catch (error) { +// result = Err(error as Error); +// } +// } +// } +// if (!result.ok) { +// throw result.error; +// } +// } +// } + export function encapsulate(op: () => Operation): Operation { return op(); } +export function trap(op: () => Operation): Operation { + return op(); +} + // export function createTask(options: TaskOptions): NewTask { // let { owner, operation } = options; // let [scope, destroy] = createScopeInternal(owner); diff --git a/test/run.test.ts b/test/run.test.ts index 7db36a04d..b5d161495 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -162,21 +162,6 @@ describe("run()", () => { expect(completed).toEqual(true); }); - // it.skip("cannot explicitly suspend in a finally block", async () => { - // let done = false; - // let task = run(function* () { - // try { - // yield* suspend(); - // } finally { - // yield* suspend(); - // done = true; - // } - // }); - - // await run(task.halt); - // expect(done).toEqual(true); - // }); - it("can suspend in yielded finally block", async () => { let things: string[] = []; @@ -201,7 +186,7 @@ describe("run()", () => { expect(things).toEqual(["first", "second"]); }); - it("can be halted while in the generator", async () => { + it.only("can be halted while in the generator", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -236,7 +221,7 @@ describe("run()", () => { await expect(task).rejects.toMatchObject({ message: "halted" }); }); - it("can delay halt if child fails", async () => { + it.skip("can delay halt if child fails", async () => { let didRun = false; let task = run(function* Main() { yield* spawn(function* willBoom() { @@ -276,7 +261,7 @@ describe("run()", () => { await expect(task.halt()).rejects.toEqual(error); }); - it("can throw error when child blows up", async () => { + it.skip("can throw error when child blows up", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -325,7 +310,7 @@ describe("run()", () => { } }); - it("successfully halts when task fails, but shutdown succeeds ", async () => { + it.skip("successfully halts when task fails, but shutdown succeeds ", async () => { let task = run(function* () { throw new Error("boom!"); }); diff --git a/test/scoped.test.ts b/test/scoped.test.ts index ff9853a09..1628f1dc1 100644 --- a/test/scoped.test.ts +++ b/test/scoped.test.ts @@ -156,7 +156,7 @@ describe("scoped", () => { })); }); - it.skip("throws errors at the correct point when there are multiple nested scopes", async () =>{ + it.skip("throws errors at the correct point when there are multiple nested scopes", async () => { let task = run(function* () { return yield* scoped(function* () { yield* spawn(function* () { From 223fe6d1260c767d54c38607d5937085e581e14e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 9 Jun 2025 15:59:25 +0300 Subject: [PATCH 040/119] Use a crash context for linking tasks together --- lib/task.ts | 27 +++++++++++++++++++++++---- test/run.test.ts | 6 +++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 06ba295ac..43eeb8e79 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -28,12 +28,21 @@ export function createTask(options: TaskOptions): NewTask { { outcome: Maybe>; teardown: Result } >(); + let trap = owner.expect(TrapContext); + + scope.set(TrapContext, { outcome: Nothing>() }); + scope.ensure(function* teardown(): Operation { routine.scope.expect(TrapContext).outcome = Nothing(); routine.return(Ok()); }); - function* halt() { + scope.set(CrashContext, (trap, error) => { + trap.outcome = Just(Err(error)); + routine.return(Ok()); + }); + + function* halt(): Operation { yield* destroy(); let { outcome, teardown } = yield* finalized.future; if (!teardown.ok) { @@ -84,8 +93,6 @@ export function createTask(options: TaskOptions): NewTask { }, }) as Task; - scope.set(TrapContext, { outcome: Nothing>() }); - let routine = createCoroutine({ scope, *operation() { @@ -93,6 +100,9 @@ export function createTask(options: TaskOptions): NewTask { let outcome = yield* trapsafe(operation); + scope.set(CrashContext, () => {}); + let crash = owner.expect(CrashContext); + routine.runLevel = 2; let teardown = yield* box(destroy); @@ -100,6 +110,7 @@ export function createTask(options: TaskOptions): NewTask { if (!teardown.ok) { future.reject(teardown.error); + crash(trap, teardown.error); } else { if (outcome.exists) { let result = outcome.value; @@ -107,6 +118,7 @@ export function createTask(options: TaskOptions): NewTask { future.resolve(result.value); } else { future.reject(result.error); + crash(trap, result.error); } } else { future.reject(new Error("halted")); @@ -120,6 +132,11 @@ export function createTask(options: TaskOptions): NewTask { return { task, scope, routine, start }; } +const CrashContext = createContext<(trap: Trap, error: Error) => void>( + "@effection/crash", + () => {}, +); + // type TaskState = { // status: "pending"; // halted: boolean; @@ -139,7 +156,9 @@ interface Trap { outcome: Maybe>; } -const TrapContext = createContext>("@effection/trap"); +const TrapContext = createContext>("@effection/trap", { + outcome: Just(Err(new Error("bare trap"))), +}); function* trapsafe( op: () => Operation, diff --git a/test/run.test.ts b/test/run.test.ts index b5d161495..1fdccbd97 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -186,7 +186,7 @@ describe("run()", () => { expect(things).toEqual(["first", "second"]); }); - it.only("can be halted while in the generator", async () => { + it("can be halted while in the generator", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -221,7 +221,7 @@ describe("run()", () => { await expect(task).rejects.toMatchObject({ message: "halted" }); }); - it.skip("can delay halt if child fails", async () => { + it("can delay halt if child fails", async () => { let didRun = false; let task = run(function* Main() { yield* spawn(function* willBoom() { @@ -261,7 +261,7 @@ describe("run()", () => { await expect(task.halt()).rejects.toEqual(error); }); - it.skip("can throw error when child blows up", async () => { + it("can throw error when child blows up", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); From 77c9a8bcc78557ac44e834e451d29b9f5ec47019 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 9 Jun 2025 16:21:56 +0300 Subject: [PATCH 041/119] all run tests passing --- lib/task.ts | 6 ++++-- test/run.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 43eeb8e79..6b2482a68 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -43,12 +43,14 @@ export function createTask(options: TaskOptions): NewTask { }); function* halt(): Operation { + let interrupted = routine.runLevel < 2; + yield* destroy(); let { outcome, teardown } = yield* finalized.future; if (!teardown.ok) { throw teardown.error; } - if (outcome.exists && !outcome.value.ok) { + if (outcome.exists && interrupted && !outcome.value.ok) { throw outcome.value.error; } } @@ -118,7 +120,7 @@ export function createTask(options: TaskOptions): NewTask { future.resolve(result.value); } else { future.reject(result.error); - crash(trap, result.error); + crash(trap, result.error); } } else { future.reject(new Error("halted")); diff --git a/test/run.test.ts b/test/run.test.ts index 1fdccbd97..5c82e88f0 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -310,7 +310,7 @@ describe("run()", () => { } }); - it.skip("successfully halts when task fails, but shutdown succeeds ", async () => { + it("successfully halts when task fails, but shutdown succeeds ", async () => { let task = run(function* () { throw new Error("boom!"); }); From 685e6f17d8a1a3570104fd694ba9a4cd2af6ecc6 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 12 Jun 2025 18:44:12 +0300 Subject: [PATCH 042/119] all teardown flows through halt() --- lib/task.ts | 94 +++++++++++++++++++++++++++++++--------------- test/spawn.test.ts | 6 ++- 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 6b2482a68..0747364c9 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -3,7 +3,7 @@ import { createContext } from "./context.ts"; import { createCoroutine } from "./coroutine.ts"; import { createFuture } from "./future.ts"; import { Just, type Maybe, Nothing } from "./maybe.ts"; -import { Err, Ok, type Result } from "./result.ts"; +import { Err, Ok, type Result, unbox } from "./result.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; import type { Coroutine, Operation, Scope, Task } from "./types.ts"; @@ -21,7 +21,7 @@ export interface NewTask { export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; - let [scope, destroy] = createScopeInternal(owner); + let [scope] = createScopeInternal(owner); let future = createFuture(); let finalized = createFuture< @@ -30,28 +30,41 @@ export function createTask(options: TaskOptions): NewTask { let trap = owner.expect(TrapContext); - scope.set(TrapContext, { outcome: Nothing>() }); - - scope.ensure(function* teardown(): Operation { - routine.scope.expect(TrapContext).outcome = Nothing(); - routine.return(Ok()); - }); - scope.set(CrashContext, (trap, error) => { trap.outcome = Just(Err(error)); routine.return(Ok()); }); + scope.set(TrapContext, { outcome: Nothing>() }); + + let group = owner.expect(TaskGroupContext); + + let children = scope.set(TaskGroupContext, new TaskGroup()); + + scope.ensure(halt); + function* halt(): Operation { let interrupted = routine.runLevel < 2; - yield* destroy(); - let { outcome, teardown } = yield* finalized.future; - if (!teardown.ok) { - throw teardown.error; - } - if (outcome.exists && interrupted && !outcome.value.ok) { - throw outcome.value.error; + // halt was called before the task could ever run. + if (routine.runLevel === 0) { + routine.runLevel = 2; + finalized.resolve({ outcome: Nothing(), teardown: Ok() }); + future.reject(new Error("halted")); + } else { + routine.scope.expect(TrapContext).outcome = Nothing(); + routine.return(Ok()); + + let { outcome, teardown } = yield* finalized.future; + + group.delete(task); + + if (!teardown.ok) { + throw teardown.error; + } + if (outcome.exists && interrupted && !outcome.value.ok) { + throw outcome.value.error; + } } } @@ -95,6 +108,8 @@ export function createTask(options: TaskOptions): NewTask { }, }) as Task; + group.add(task); + let routine = createCoroutine({ scope, *operation() { @@ -107,7 +122,7 @@ export function createTask(options: TaskOptions): NewTask { routine.runLevel = 2; - let teardown = yield* box(destroy); + let teardown = yield* box(() => children.halt()); finalized.resolve({ outcome, teardown }); if (!teardown.ok) { @@ -139,20 +154,37 @@ const CrashContext = createContext<(trap: Trap, error: Error) => void>( () => {}, ); -// type TaskState = { -// status: "pending"; -// halted: boolean; -// } | { -// status: "finalizing"; -// halted: boolean; -// computation: Result; -// finalization: Result; -// } | { -// status: "finalized"; -// halted: boolean; -// computation: Result; -// finalization: Result; -// }; +class TaskGroup { + tasks = new Set>(); + + add(task: Task) { + this.tasks.add(task); + } + + delete(task: Task) { + this.tasks.delete(task); + } + + *halt() { + let total = Ok(); + while (this.tasks.size > 0) { + let tasks = [...this.tasks].reverse(); + this.tasks.clear(); + for (let task of tasks) { + let result = yield* box(task.halt); + if (!result.ok) { + total = result; + } + } + } + unbox(total); + } +} + +const TaskGroupContext = createContext( + "@effection/task-group", + new TaskGroup(), +); interface Trap { outcome: Maybe>; diff --git a/test/spawn.test.ts b/test/spawn.test.ts index 07bfce1f5..362c0b6b6 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -37,6 +37,8 @@ describe("spawn", () => { yield* suspend(); }); + yield* sleep(0); + return 1; }); @@ -51,6 +53,8 @@ describe("spawn", () => { yield* suspend(); }); + yield* sleep(0); + throw new Error("boom"); }); @@ -102,7 +106,7 @@ describe("spawn", () => { await expect(root).rejects.toHaveProperty("message", "moo"); }); - it("rejects when child errors during halting", async () => { + it.skip("rejects when child errors during halting", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { From 3ddd81be42207410ece1cc58ddd3e18291131f72 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 12 Jun 2025 22:59:33 +0300 Subject: [PATCH 043/119] Make halt idempotent --- lib/task.ts | 107 ++++++++++++++++++++++++--------------------- lib/types.ts | 2 +- test/spawn.test.ts | 2 +- 3 files changed, 60 insertions(+), 51 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 0747364c9..35ddb3c36 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-unsafe-finally import { box } from "./box.ts"; import { createContext } from "./context.ts"; import { createCoroutine } from "./coroutine.ts"; @@ -43,28 +44,28 @@ export function createTask(options: TaskOptions): NewTask { scope.ensure(halt); - function* halt(): Operation { - let interrupted = routine.runLevel < 2; + let interrupted = false; + function* halt(): Operation { // halt was called before the task could ever run. if (routine.runLevel === 0) { - routine.runLevel = 2; + routine.runLevel = 3; finalized.resolve({ outcome: Nothing(), teardown: Ok() }); future.reject(new Error("halted")); - } else { + } else if (routine.runLevel < 3) { + interrupted = routine.runLevel < 2; routine.scope.expect(TrapContext).outcome = Nothing(); routine.return(Ok()); - - let { outcome, teardown } = yield* finalized.future; - group.delete(task); + } - if (!teardown.ok) { - throw teardown.error; - } - if (outcome.exists && interrupted && !outcome.value.ok) { - throw outcome.value.error; - } + let { outcome, teardown } = yield* finalized.future; + + if (!teardown.ok) { + throw teardown.error; + } + if (outcome.exists && interrupted && !outcome.value.ok) { + throw outcome.value.error; } } @@ -123,6 +124,9 @@ export function createTask(options: TaskOptions): NewTask { routine.runLevel = 2; let teardown = yield* box(() => children.halt()); + + routine.runLevel = 3; + finalized.resolve({ outcome, teardown }); if (!teardown.ok) { @@ -207,7 +211,6 @@ function* trapsafe( } catch (error) { trap.outcome = Just(Err(error as Error)); } finally { - // deno-lint-ignore no-unsafe-finally return (yield { description: "trapset return", enter(resolve) { @@ -218,37 +221,37 @@ function* trapsafe( } } -// export function* trap(op: () => Operation): Operation { -// let original = yield* TrapContext.expect(); -// let trap: Trap = { outcome: Nothing>() }; -// try { -// yield* TrapContext.set(trap); -// let value = yield* op(); -// trap.outcome = Just(Ok(value)); -// } catch (error) { -// trap.outcome = Just(Err(error as Error)); -// } finally { -// yield* TrapContext.set(original); -// const { outcome } = trap; -// if (outcome.exists) { -// const { value: result } = outcome; -// if (result.ok) { -// return result.value; -// } else { -// throw result.error; -// } -// } else { -// return (yield { -// description: "propagate halt", -// enter(_, routine) { -// original.outcome = Nothing(); -// routine.return(Ok()); -// return (didExit) => didExit(Ok()); -// }, -// }) as T; -// } -// } -// } +export function* trap(op: () => Operation): Operation { + let original = yield* TrapContext.expect(); + let trap: Trap = { outcome: Nothing>() }; + try { + yield* TrapContext.set(trap); + let value = yield* op(); + trap.outcome = Just(Ok(value)); + } catch (error) { + trap.outcome = Just(Err(error as Error)); + } finally { + yield* TrapContext.set(original); + const { outcome } = trap; + if (outcome.exists) { + const { value: result } = outcome; + if (result.ok) { + return result.value; + } else { + throw result.error; + } + } else { + return (yield { + description: "propagate halt", + enter(_, routine) { + original.outcome = Nothing(); + routine.return(Ok()); + return (didExit) => didExit(Ok()); + }, + }) as T; + } + } +} // export function createTask(options: TaskOptions): NewTask { // let { owner, operation } = options; @@ -448,12 +451,18 @@ function* trapsafe( // } export function encapsulate(op: () => Operation): Operation { - return op(); + return TaskGroupContext.with(new TaskGroup(), function* (group) { + try { + return yield* op(); + } finally { + yield* group.halt(); + } + }); } -export function trap(op: () => Operation): Operation { - return op(); -} +// export function trap(op: () => Operation): Operation { +// return op(); +// } // export function createTask(options: TaskOptions): NewTask { // let { owner, operation } = options; diff --git a/lib/types.ts b/lib/types.ts index 796e8d25c..ea6268e03 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -355,7 +355,7 @@ export interface Effect { * @ignore */ export interface Coroutine { - runLevel: 0 | 1 | 2; + runLevel: 0 | 1 | 2 | 3; scope: Scope; data: { exit(resolve: Resolve>): void; diff --git a/test/spawn.test.ts b/test/spawn.test.ts index 362c0b6b6..0d081eae7 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -106,7 +106,7 @@ describe("spawn", () => { await expect(root).rejects.toHaveProperty("message", "moo"); }); - it.skip("rejects when child errors during halting", async () => { + it("rejects when child errors during halting", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { From fe59e6b947b0a5f690387db681ba606a9dd89559 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 12 Jun 2025 23:38:56 +0300 Subject: [PATCH 044/119] fix resource tests --- lib/task.ts | 10 ++++++++++ test/all.test.ts | 6 +++--- test/each.test.ts | 8 ++++---- test/lift.test.ts | 3 ++- test/main.test.ts | 2 +- test/resource.test.ts | 4 ++-- 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index 35ddb3c36..35ab82d0b 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -233,6 +233,16 @@ export function* trap(op: () => Operation): Operation { } finally { yield* TrapContext.set(original); const { outcome } = trap; + + Object.defineProperty(trap, "outcome", { + set(value: Maybe>) { + original.outcome = value; + }, + get() { + return original.outcome; + }, + }); + if (outcome.exists) { const { value: result } = outcome; if (result.ok) { diff --git a/test/all.test.ts b/test/all.test.ts index b908af21c..e9f52e03b 100644 --- a/test/all.test.ts +++ b/test/all.test.ts @@ -32,7 +32,7 @@ describe("all()", () => { expect(result).toEqual(["quox", "foo", "bar", "baz"]); }); - it("rejects when one of the given operations rejects asynchronously first", async () => { + it.skip("rejects when one of the given operations rejects asynchronously first", async () => { let result = run(() => all([ asyncResolve(10, "foo"), @@ -44,7 +44,7 @@ describe("all()", () => { await expect(result).rejects.toHaveProperty("message", "boom: bar"); }); - it("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { + it.skip("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { let result = run(() => all([sleep(0), asyncReject(5, "bar")])); await expect(result).rejects.toHaveProperty("message", "boom: bar"); @@ -74,7 +74,7 @@ describe("all()", () => { await expect(result).rejects.toHaveProperty("message", "boom: bar"); }); - it("rejects when one of the operations reject", async () => { + it.skip("rejects when one of the operations reject", async () => { await run(function* () { let fooStatus = { status: "pending" }; let error; diff --git a/test/each.test.ts b/test/each.test.ts index 9a76188d2..736175a55 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -9,7 +9,7 @@ import { } from "../mod.ts"; describe("each", () => { - it("can be used to iterate a stream", async () => { + it.skip("can be used to iterate a stream", async () => { await run(function* () { let actual = [] as string[]; let channel = sequence("one", "two", "three"); @@ -26,7 +26,7 @@ describe("each", () => { }); }); - it("can be used to iterate nested streams", async () => { + it.skip("can be used to iterate nested streams", async () => { await run(function* () { let actual = [] as string[]; let outer = sequence("one", "two"); @@ -54,7 +54,7 @@ describe("each", () => { }); }); - it("handles context correctly if you break out of a loop", async () => { + it.skip("handles context correctly if you break out of a loop", async () => { await expect(run(function* () { let seq = sequence("hello world"); @@ -67,7 +67,7 @@ describe("each", () => { })).rejects.toHaveProperty("name", "IterationError"); }); - it("throws an error if you forget to invoke each.next()", async () => { + it.skip("throws an error if you forget to invoke each.next()", async () => { await expect(run(function* () { let seq = sequence("hello"); diff --git a/test/lift.test.ts b/test/lift.test.ts index ef228171c..8443e5648 100644 --- a/test/lift.test.ts +++ b/test/lift.test.ts @@ -1,8 +1,9 @@ + import { lift, run, spawn, withResolvers } from "../mod.ts"; import { describe, expect, it } from "./suite.ts"; describe("lift", () => { - it("safely does not continue if the call stops the operation", async () => { + it.skip("safely does not continue if the call stops the operation", async () => { let reached = false; await run(function* main() { diff --git a/test/main.test.ts b/test/main.test.ts index 62c296c4f..a91fc0adc 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -10,7 +10,7 @@ function* until(stream: Stream, text: string) { } } -describe("main", () => { +describe.skip("main", () => { it("gracefully shuts down on SIGINT", async () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); diff --git a/test/resource.test.ts b/test/resource.test.ts index 3d6d8d22f..e0ac3c6dd 100644 --- a/test/resource.test.ts +++ b/test/resource.test.ts @@ -58,12 +58,12 @@ describe("resource", () => { yield* provide(); }); try { - yield* sleep(5); + yield* suspend(); } catch (error) { return error; } }); - await expect(task).rejects.toHaveProperty("message", "moo"); + await expect(task).rejects.toMatchObject({ message: "moo" }); }); it("terminates resource when task completes", async () => { From 7dc3eeeb30e4caf48c0ceef41684e5f8c6845961 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 17 Jun 2025 13:21:38 +0300 Subject: [PATCH 045/119] Immediately transition to run level 2 when halting Once we transition to run level 2, then old instructions currently in the queue are dropped. --- lib/all.ts | 28 ++++++++++++++-------------- lib/race.ts | 40 +++++++++++++++++++--------------------- lib/scoped.ts | 4 ++-- lib/task.ts | 3 ++- test/all.test.ts | 6 +++--- test/each.test.ts | 8 ++++---- test/lift.test.ts | 3 +-- test/main.test.ts | 2 +- test/race.test.ts | 6 +++--- 9 files changed, 49 insertions(+), 51 deletions(-) diff --git a/lib/all.ts b/lib/all.ts index d386587fe..ff3d8172b 100644 --- a/lib/all.ts +++ b/lib/all.ts @@ -1,6 +1,6 @@ import type { Operation, Task, Yielded } from "./types.ts"; import { spawn } from "./spawn.ts"; -import { encapsulate, trap } from "./task.ts"; +import { trap } from "./task.ts"; /** * Block and wait for all of the given operations to complete. Returns @@ -30,20 +30,20 @@ import { encapsulate, trap } from "./task.ts"; export function* all[] | []>( ops: T, ): Operation> { - return yield* trap(() => - encapsulate(function* (): Operation> { - let tasks: Task[] = []; + let tasks: Task[] = []; - for (let operation of ops) { - tasks.push(yield* spawn(() => operation)); - } - let results = []; - for (let task of tasks) { - results.push(yield* task); - } - return results as All; - }) - ); + return yield* trap(function* (): Operation> { + for (let operation of ops) { + const member = () => operation; + tasks.push(yield* spawn(member)); + } + let results: unknown[] = []; + for (let task of tasks) { + let result = yield* task; + results.push(result); + } + return results as All; + }); } /** diff --git a/lib/race.ts b/lib/race.ts index 0c75f54e9..6ece70c80 100644 --- a/lib/race.ts +++ b/lib/race.ts @@ -3,6 +3,7 @@ import { encapsulate, trap } from "./task.ts"; import type { Operation, Task, Yielded } from "./types.ts"; import { withResolvers } from "./with-resolvers.ts"; import { Err, Ok, type Result } from "./result.ts"; +import { lift } from "./lift.ts"; //import { useScope } from "./scope.ts"; //import { transfer } from "./scope.ts"; @@ -39,27 +40,24 @@ export function* race>( let tasks: Task[] = []; // encapsulate the race in a hermetic scope. - let result = yield* trap(() => - encapsulate(function* () { - for (let operation of operations.slice()) { - tasks.push( - yield* spawn(function* () { - // let contestant = yield* useScope(); - try { - let value = yield* operation; - - // Transfer the winner to the contestant - // transfer({ from: contestant, to: caller }); - winner.resolve(Ok(value as Yielded)); - } catch (error) { - winner.resolve(Err(error as Error)); - } - }), - ); - } - return yield* winner.operation; - }) - ); + let result = yield* trap(function* () { + for (let operation of operations.slice()) { + tasks.push( + yield* spawn(function* candidate() { + // let contestant = yield* useScope(); + try { + let value = yield* operation; + // Transfer the winner to the contestant + // transfer({ from: contestant, to: caller }); + winner.resolve(Ok(value as Yielded)); + } catch (error) { + winner.resolve(Err(error as Error)); + } + }), + ); + } + return yield* winner.operation; + }); let shutdown: Task[] = []; diff --git a/lib/scoped.ts b/lib/scoped.ts index 3bd66731d..7c6e5a556 100644 --- a/lib/scoped.ts +++ b/lib/scoped.ts @@ -1,5 +1,5 @@ import type { Operation } from "./types.ts"; -import { trap } from "./task.ts"; +import { encapsulate, trap } from "./task.ts"; import { useCoroutine } from "./coroutine.ts"; import { createScopeInternal } from "./scope-internal.ts"; @@ -33,7 +33,7 @@ export function scoped(operation: () => Operation): Operation { let [scope, destroy] = createScopeInternal(original); try { routine.scope = scope; - return yield* trap(operation); + return yield* trap(() => encapsulate(operation)); } finally { routine.scope = original; yield* destroy(); diff --git a/lib/task.ts b/lib/task.ts index 35ab82d0b..da0ba0b6e 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -54,6 +54,7 @@ export function createTask(options: TaskOptions): NewTask { future.reject(new Error("halted")); } else if (routine.runLevel < 3) { interrupted = routine.runLevel < 2; + routine.runLevel = 2; routine.scope.expect(TrapContext).outcome = Nothing(); routine.return(Ok()); group.delete(task); @@ -169,7 +170,7 @@ class TaskGroup { this.tasks.delete(task); } - *halt() { + *halt(): Operation { let total = Ok(); while (this.tasks.size > 0) { let tasks = [...this.tasks].reverse(); diff --git a/test/all.test.ts b/test/all.test.ts index e9f52e03b..5d8d0c08b 100644 --- a/test/all.test.ts +++ b/test/all.test.ts @@ -32,7 +32,7 @@ describe("all()", () => { expect(result).toEqual(["quox", "foo", "bar", "baz"]); }); - it.skip("rejects when one of the given operations rejects asynchronously first", async () => { + it("rejects when one of the given operations rejects asynchronously first", async () => { let result = run(() => all([ asyncResolve(10, "foo"), @@ -47,7 +47,7 @@ describe("all()", () => { it.skip("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { let result = run(() => all([sleep(0), asyncReject(5, "bar")])); - await expect(result).rejects.toHaveProperty("message", "boom: bar"); + await expect(result).rejects.toMatchObject({ message: "boom: bar"}); }); it("resolves when all of the given operations resolve synchronously", async () => { @@ -74,7 +74,7 @@ describe("all()", () => { await expect(result).rejects.toHaveProperty("message", "boom: bar"); }); - it.skip("rejects when one of the operations reject", async () => { + it("rejects when one of the operations reject", async () => { await run(function* () { let fooStatus = { status: "pending" }; let error; diff --git a/test/each.test.ts b/test/each.test.ts index 736175a55..9a76188d2 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -9,7 +9,7 @@ import { } from "../mod.ts"; describe("each", () => { - it.skip("can be used to iterate a stream", async () => { + it("can be used to iterate a stream", async () => { await run(function* () { let actual = [] as string[]; let channel = sequence("one", "two", "three"); @@ -26,7 +26,7 @@ describe("each", () => { }); }); - it.skip("can be used to iterate nested streams", async () => { + it("can be used to iterate nested streams", async () => { await run(function* () { let actual = [] as string[]; let outer = sequence("one", "two"); @@ -54,7 +54,7 @@ describe("each", () => { }); }); - it.skip("handles context correctly if you break out of a loop", async () => { + it("handles context correctly if you break out of a loop", async () => { await expect(run(function* () { let seq = sequence("hello world"); @@ -67,7 +67,7 @@ describe("each", () => { })).rejects.toHaveProperty("name", "IterationError"); }); - it.skip("throws an error if you forget to invoke each.next()", async () => { + it("throws an error if you forget to invoke each.next()", async () => { await expect(run(function* () { let seq = sequence("hello"); diff --git a/test/lift.test.ts b/test/lift.test.ts index 8443e5648..ef228171c 100644 --- a/test/lift.test.ts +++ b/test/lift.test.ts @@ -1,9 +1,8 @@ - import { lift, run, spawn, withResolvers } from "../mod.ts"; import { describe, expect, it } from "./suite.ts"; describe("lift", () => { - it.skip("safely does not continue if the call stops the operation", async () => { + it("safely does not continue if the call stops the operation", async () => { let reached = false; await run(function* main() { diff --git a/test/main.test.ts b/test/main.test.ts index a91fc0adc..62c296c4f 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -10,7 +10,7 @@ function* until(stream: Stream, text: string) { } } -describe.skip("main", () => { +describe("main", () => { it("gracefully shuts down on SIGINT", async () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); diff --git a/test/race.test.ts b/test/race.test.ts index d1ae33d72..8738a9ef2 100644 --- a/test/race.test.ts +++ b/test/race.test.ts @@ -44,19 +44,19 @@ describe("race()", () => { await expect(result).rejects.toHaveProperty("message", "boom: bar"); }); - it.skip("resolves when one of the given operations resolves synchronously first", async () => { + it("resolves when one of the given operations resolves synchronously first", async () => { let result = run(() => race([ syncResolve("foo"), syncResolve("bar"), - syncReject("baz"), + syncReject("baz"), ]) ); await expect(result).resolves.toEqual("foo"); }); - it.skip("rejects when one of the given operations rejects synchronously first", async () => { + it("rejects when one of the given operations rejects synchronously first", async () => { let result = run(() => race([ syncReject("foo"), From 076e6f7d7ef1ff76b51071e40cbf4a360aa22c93 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 17 Jun 2025 13:36:24 +0300 Subject: [PATCH 046/119] cleanup encapsulate and remove a bunch of dead code --- lib/race.ts | 4 +-- lib/reducer.ts | 37 ++------------------ lib/scope-internal.ts | 2 +- lib/scoped.ts | 4 +-- tasks/bench/scenarios/scenario.ts | 56 +++++++++++++++---------------- test/all.test.ts | 2 +- test/race.test.ts | 2 +- 7 files changed, 36 insertions(+), 71 deletions(-) diff --git a/lib/race.ts b/lib/race.ts index 6ece70c80..a234f2e1e 100644 --- a/lib/race.ts +++ b/lib/race.ts @@ -1,9 +1,9 @@ import { spawn } from "./spawn.ts"; -import { encapsulate, trap } from "./task.ts"; +import { trap } from "./task.ts"; import type { Operation, Task, Yielded } from "./types.ts"; import { withResolvers } from "./with-resolvers.ts"; import { Err, Ok, type Result } from "./result.ts"; -import { lift } from "./lift.ts"; + //import { useScope } from "./scope.ts"; //import { transfer } from "./scope.ts"; diff --git a/lib/reducer.ts b/lib/reducer.ts index 7ae135e84..8594d583c 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -5,7 +5,7 @@ import type { Coroutine } from "./types.ts"; export class Reducer { reducing = false; - readonly queue = createPriorityQueue2(); + readonly queue = createPriorityQueue(); reduce = ( thunk: Thunk, @@ -72,29 +72,7 @@ type Thunk = [ number, ]; -// // This is a pretty hokey priority queue that uses an array for storage -// // so enqueue is O(n). However, `n` is generally small. revisit. -// function createPriorityQueue() { -// let thunks: Thunk[] = []; - -// return { -// enqueue(thunk: Thunk): void { -// let [priority] = thunk; -// let index = thunks.findIndex(([p]) => p >= priority); -// if (index === -1) { -// thunks.push(thunk); -// } else { -// thunks.splice(index, 0, thunk); -// } -// }, - -// dequeue(): Thunk | undefined { -// return thunks.shift(); -// }, -// }; -// } - -function createPriorityQueue2() { +function createPriorityQueue() { let q = new PriorityQueue(); return { @@ -116,14 +94,3 @@ function createPriorityQueue2() { }, }; } - -function qdir(cxt: string, q: PriorityQueue): void { - console.log(cxt); - console.dir( - q.tiers.filter((t) => !!t).map((t) => ({ - priority: t.priority, - items: t.items.map((i) => ({ c: i[1], r: i[2], t: i[4], v: i[5] })), - })), - { depth: 10 }, - ); -} diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index d243cdc43..96c142d41 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -2,7 +2,7 @@ import { Children, Generation } from "./contexts.ts"; import { Err, Ok, unbox } from "./result.ts"; import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; -import { WithResolvers, withResolvers } from "./with-resolvers.ts"; +import { type WithResolvers, withResolvers } from "./with-resolvers.ts"; export function createScopeInternal( parent?: Scope, diff --git a/lib/scoped.ts b/lib/scoped.ts index 7c6e5a556..3bd66731d 100644 --- a/lib/scoped.ts +++ b/lib/scoped.ts @@ -1,5 +1,5 @@ import type { Operation } from "./types.ts"; -import { encapsulate, trap } from "./task.ts"; +import { trap } from "./task.ts"; import { useCoroutine } from "./coroutine.ts"; import { createScopeInternal } from "./scope-internal.ts"; @@ -33,7 +33,7 @@ export function scoped(operation: () => Operation): Operation { let [scope, destroy] = createScopeInternal(original); try { routine.scope = scope; - return yield* trap(() => encapsulate(operation)); + return yield* trap(operation); } finally { routine.scope = original; yield* destroy(); diff --git a/tasks/bench/scenarios/scenario.ts b/tasks/bench/scenarios/scenario.ts index ebfff2644..3b50b9aee 100644 --- a/tasks/bench/scenarios/scenario.ts +++ b/tasks/bench/scenarios/scenario.ts @@ -25,42 +25,40 @@ export function scenario( ) { return main(function* () { try { - yield* encapsulate(() => - callcc(function* (exit) { - let work = createChannel(); - yield* spawn(function* () { - for (let command of yield* each(commands)) { - if (command.type === "close") { - yield* exit(); - } else { - yield* work.send(command); - } - yield* each.next(); + yield* callcc(function* (exit) { + let work = createChannel(); + yield* spawn(function* () { + for (let command of yield* each(commands)) { + if (command.type === "close") { + yield* exit(); + } else { + yield* work.send(command); } - }); + yield* each.next(); + } + }); - for (let options of yield* each(work)) { - let times: number[] = []; - for (let i = 0; i < options.repeat; i++) { - let start = performance.now(); + for (let options of yield* each(work)) { + let times: number[] = []; + for (let i = 0; i < options.repeat; i++) { + let start = performance.now(); - yield* encapsulate(() => perform(options.depth)); + yield* encapsulate(() => perform(options.depth)); - let time = performance.now() - start; - send({ type: "repeat", name, time, rep: i + 1 }); - times.push(time); - } + let time = performance.now() - start; + send({ type: "repeat", name, time, rep: i + 1 }); + times.push(time); + } - let total = times.reduce((sum, time) => sum + time, 0); - let avgTime = total / times.length; - let result = Ok({ avgTime, reps: options.repeat }); + let total = times.reduce((sum, time) => sum + time, 0); + let avgTime = total / times.length; + let result = Ok({ avgTime, reps: options.repeat }); - send({ type: "done", name, result }); + send({ type: "done", name, result }); - yield* each.next(); - } - }) - ); + yield* each.next(); + } + }); } catch (error) { send({ type: "done", name, result: Err(error as Error) }); } finally { diff --git a/test/all.test.ts b/test/all.test.ts index 5d8d0c08b..56d78601d 100644 --- a/test/all.test.ts +++ b/test/all.test.ts @@ -47,7 +47,7 @@ describe("all()", () => { it.skip("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { let result = run(() => all([sleep(0), asyncReject(5, "bar")])); - await expect(result).rejects.toMatchObject({ message: "boom: bar"}); + await expect(result).rejects.toMatchObject({ message: "boom: bar" }); }); it("resolves when all of the given operations resolve synchronously", async () => { diff --git a/test/race.test.ts b/test/race.test.ts index 8738a9ef2..07486eade 100644 --- a/test/race.test.ts +++ b/test/race.test.ts @@ -49,7 +49,7 @@ describe("race()", () => { race([ syncResolve("foo"), syncResolve("bar"), - syncReject("baz"), + syncReject("baz"), ]) ); From c1ae865a91ad7197837aac56cad4c07330aadbd5 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 28 Jun 2025 14:53:00 +0300 Subject: [PATCH 047/119] Extract Trap -> Boundary --- lib/boundary.ts | 13 +++++++++++++ lib/task.ts | 27 ++++++++++----------------- 2 files changed, 23 insertions(+), 17 deletions(-) create mode 100644 lib/boundary.ts diff --git a/lib/boundary.ts b/lib/boundary.ts new file mode 100644 index 000000000..c3365eabd --- /dev/null +++ b/lib/boundary.ts @@ -0,0 +1,13 @@ +import { createContext } from "./context.ts"; +import { Just, type Maybe } from "./maybe.ts"; +import { Err, type Result } from "./result.ts"; + +export interface Boundary { + outcome: Maybe>; + runLevel: 0; +} + +export const BoundaryContext = createContext>("@effection/trap", { + outcome: Just(Err(new Error("unbounded context"))), + runLevel: 0, +}); diff --git a/lib/task.ts b/lib/task.ts index da0ba0b6e..af86ce67c 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,4 +1,5 @@ // deno-lint-ignore-file no-unsafe-finally +import { type Boundary, BoundaryContext } from "./boundary.ts"; import { box } from "./box.ts"; import { createContext } from "./context.ts"; import { createCoroutine } from "./coroutine.ts"; @@ -29,14 +30,14 @@ export function createTask(options: TaskOptions): NewTask { { outcome: Maybe>; teardown: Result } >(); - let trap = owner.expect(TrapContext); + let trap = owner.expect(BoundaryContext); scope.set(CrashContext, (trap, error) => { trap.outcome = Just(Err(error)); routine.return(Ok()); }); - scope.set(TrapContext, { outcome: Nothing>() }); + scope.set(BoundaryContext, { outcome: Nothing>(), runLevel: 0 }); let group = owner.expect(TaskGroupContext); @@ -55,7 +56,7 @@ export function createTask(options: TaskOptions): NewTask { } else if (routine.runLevel < 3) { interrupted = routine.runLevel < 2; routine.runLevel = 2; - routine.scope.expect(TrapContext).outcome = Nothing(); + routine.scope.expect(BoundaryContext).outcome = Nothing(); routine.return(Ok()); group.delete(task); } @@ -154,7 +155,7 @@ export function createTask(options: TaskOptions): NewTask { return { task, scope, routine, start }; } -const CrashContext = createContext<(trap: Trap, error: Error) => void>( +const CrashContext = createContext<(trap: Boundary, error: Error) => void>( "@effection/crash", () => {}, ); @@ -191,18 +192,10 @@ const TaskGroupContext = createContext( new TaskGroup(), ); -interface Trap { - outcome: Maybe>; -} - -const TrapContext = createContext>("@effection/trap", { - outcome: Just(Err(new Error("bare trap"))), -}); - function* trapsafe( op: () => Operation, ): Operation>> { - let trap = yield* TrapContext.expect(); + let trap = yield* BoundaryContext.expect(); let original = trap.outcome; try { let value = yield* op(); @@ -223,16 +216,16 @@ function* trapsafe( } export function* trap(op: () => Operation): Operation { - let original = yield* TrapContext.expect(); - let trap: Trap = { outcome: Nothing>() }; + let original = yield* BoundaryContext.expect(); + let trap: Boundary = { outcome: Nothing>(), runLevel: 0 }; try { - yield* TrapContext.set(trap); + yield* BoundaryContext.set(trap); let value = yield* op(); trap.outcome = Just(Ok(value)); } catch (error) { trap.outcome = Just(Err(error as Error)); } finally { - yield* TrapContext.set(original); + yield* BoundaryContext.set(original); const { outcome } = trap; Object.defineProperty(trap, "outcome", { From 0346a19b9978661be50372a101a7b982553989f4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 28 Jun 2025 14:57:21 +0300 Subject: [PATCH 048/119] thunk -> instruction --- lib/boundary.ts | 11 +++++++---- lib/reducer.ts | 16 ++++++++-------- lib/task.ts | 4 +++- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/boundary.ts b/lib/boundary.ts index c3365eabd..7947f48de 100644 --- a/lib/boundary.ts +++ b/lib/boundary.ts @@ -7,7 +7,10 @@ export interface Boundary { runLevel: 0; } -export const BoundaryContext = createContext>("@effection/trap", { - outcome: Just(Err(new Error("unbounded context"))), - runLevel: 0, -}); +export const BoundaryContext = createContext>( + "@effection/trap", + { + outcome: Just(Err(new Error("unbounded context"))), + runLevel: 0, + }, +); diff --git a/lib/reducer.ts b/lib/reducer.ts index 8594d583c..77008cd4a 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -8,11 +8,11 @@ export class Reducer { readonly queue = createPriorityQueue(); reduce = ( - thunk: Thunk, + instruction: Instruction, ) => { let { queue } = this; - queue.enqueue(thunk); + queue.enqueue(instruction); if (this.reducing) return; @@ -63,7 +63,7 @@ export const ReducerContext = createContext( new Reducer(), ); -type Thunk = [ +type Instruction = [ number, Coroutine, Result, @@ -73,14 +73,14 @@ type Thunk = [ ]; function createPriorityQueue() { - let q = new PriorityQueue(); + let q = new PriorityQueue(); return { - enqueue(thunk: Thunk): void { - let [priority] = thunk; - q.push(priority, thunk); + enqueue(instruction: Instruction): void { + let [priority] = instruction; + q.push(priority, instruction); }, - dequeue(): Thunk | undefined { + dequeue(): Instruction | undefined { while (true) { let top = q.pop(); if (!top) { diff --git a/lib/task.ts b/lib/task.ts index af86ce67c..4286cdc3d 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -155,7 +155,9 @@ export function createTask(options: TaskOptions): NewTask { return { task, scope, routine, start }; } -const CrashContext = createContext<(trap: Boundary, error: Error) => void>( +const CrashContext = createContext< + (trap: Boundary, error: Error) => void +>( "@effection/crash", () => {}, ); From 72522cc61edc86b5f6a7cd89bb94df752b0469cd Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 30 Jun 2025 19:21:23 +0300 Subject: [PATCH 049/119] Use a boundary with a level indicating to invalidate instructions --- lib/coroutine.ts | 7 +++++++ lib/reducer.ts | 54 +++++++++++++++++++++++++++--------------------- lib/task.ts | 14 ++++++++++--- test/all.test.ts | 4 ++-- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/lib/coroutine.ts b/lib/coroutine.ts index 8c6a49665..6bb623602 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -1,3 +1,4 @@ +import { BoundaryContext } from "./boundary.ts"; import { Generation } from "./contexts.ts"; import { ReducerContext } from "./reducer.ts"; import { Ok } from "./result.ts"; @@ -30,6 +31,7 @@ export function createCoroutine( next(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); + const boundary = routine.scope.expect(BoundaryContext); reducer.reduce([ scope.expect(Generation), routine, @@ -37,12 +39,15 @@ export function createCoroutine( () => {}, "next", routine.runLevel, + boundary, + boundary.runLevel, ]); }); }, return(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); + const boundary = routine.scope.expect(BoundaryContext); reducer.reduce([ scope.expect(Generation), routine, @@ -50,6 +55,8 @@ export function createCoroutine( () => {}, "return", routine.runLevel, + boundary, + boundary.runLevel, ]); }); }, diff --git a/lib/reducer.ts b/lib/reducer.ts index 77008cd4a..cdbe7051a 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -1,3 +1,4 @@ +import { type Boundary, BoundaryContext } from "./boundary.ts"; import { createContext } from "./context.ts"; import { PriorityQueue } from "./priority-queue.ts"; import { Err, type Result } from "./result.ts"; @@ -5,7 +6,7 @@ import type { Coroutine } from "./types.ts"; export class Reducer { reducing = false; - readonly queue = createPriorityQueue(); + readonly queue = new InstructionQueue(); reduce = ( instruction: Instruction, @@ -58,11 +59,6 @@ export class Reducer { }; } -export const ReducerContext = createContext( - "@effection/reducer", - new Reducer(), -); - type Instruction = [ number, Coroutine, @@ -70,27 +66,37 @@ type Instruction = [ () => void, "return" | "next", number, + Boundary, + number, ]; -function createPriorityQueue() { - let q = new PriorityQueue(); - - return { - enqueue(instruction: Instruction): void { - let [priority] = instruction; - q.push(priority, instruction); - }, - dequeue(): Instruction | undefined { - while (true) { - let top = q.pop(); - if (!top) { - return undefined; - } else if (top[5] < top[1].runLevel) { +class InstructionQueue extends PriorityQueue { + enqueue(instruction: Instruction): void { + let [priority] = instruction; + this.push(priority, instruction); + } + dequeue(): Instruction | undefined { + while (true) { + let top = this.pop(); + if (!top) { + return undefined; + } else if (top[5] < top[1].runLevel) { + continue; + } else { + let coroutine = top[1]; + let boundary = top[6]; + let level = top[7]; + let current = coroutine.scope.expect(BoundaryContext); + if (current !== boundary || current.runLevel !== level) { continue; - } else { - return top; } + return top; } - }, - }; + } + } } + +export const ReducerContext = createContext( + "@effection/reducer", + new Reducer(), +); diff --git a/lib/task.ts b/lib/task.ts index 4286cdc3d..5766cdf38 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -2,7 +2,7 @@ import { type Boundary, BoundaryContext } from "./boundary.ts"; import { box } from "./box.ts"; import { createContext } from "./context.ts"; -import { createCoroutine } from "./coroutine.ts"; +import { createCoroutine, useCoroutine } from "./coroutine.ts"; import { createFuture } from "./future.ts"; import { Just, type Maybe, Nothing } from "./maybe.ts"; import { Err, Ok, type Result, unbox } from "./result.ts"; @@ -53,8 +53,12 @@ export function createTask(options: TaskOptions): NewTask { routine.runLevel = 3; finalized.resolve({ outcome: Nothing(), teardown: Ok() }); future.reject(new Error("halted")); - } else if (routine.runLevel < 3) { - interrupted = routine.runLevel < 2; + } else if (routine.runLevel === 2) { + //console.log("HALTING LEVEL 2"); + // happens when a child is completing, and waiting for trap safe return + // but parent decides to halt it. + } else if (routine.runLevel < 2) { + interrupted = true; routine.runLevel = 2; routine.scope.expect(BoundaryContext).outcome = Nothing(); routine.return(Ok()); @@ -120,6 +124,8 @@ export function createTask(options: TaskOptions): NewTask { let outcome = yield* trapsafe(operation); + group.delete(task); + scope.set(CrashContext, () => {}); let crash = owner.expect(CrashContext); @@ -197,6 +203,7 @@ const TaskGroupContext = createContext( function* trapsafe( op: () => Operation, ): Operation>> { + let routine = yield* useCoroutine(); let trap = yield* BoundaryContext.expect(); let original = trap.outcome; try { @@ -207,6 +214,7 @@ function* trapsafe( } catch (error) { trap.outcome = Just(Err(error as Error)); } finally { + routine.runLevel = 2; return (yield { description: "trapset return", enter(resolve) { diff --git a/test/all.test.ts b/test/all.test.ts index 56d78601d..73fdfab6f 100644 --- a/test/all.test.ts +++ b/test/all.test.ts @@ -44,7 +44,7 @@ describe("all()", () => { await expect(result).rejects.toHaveProperty("message", "boom: bar"); }); - it.skip("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { + it("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { let result = run(() => all([sleep(0), asyncReject(5, "bar")])); await expect(result).rejects.toMatchObject({ message: "boom: bar" }); @@ -62,7 +62,7 @@ describe("all()", () => { await expect(result).resolves.toEqual(["foo", "bar", "baz"]); }); - it.skip("rejects when one of the given operations rejects synchronously first", async () => { + it("rejects when one of the given operations rejects synchronously first", async () => { let result = run(() => all([ syncResolve("foo"), From feaa080ff514a80ea9279856f827ec3d8167f8dd Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 30 Jun 2025 21:44:35 +0300 Subject: [PATCH 050/119] begin unifying run levels --- lib/delimiter.ts | 57 ++++++ lib/task.ts | 457 ++++++++++++++++++++++++++++------------------- test/run.test.ts | 6 +- 3 files changed, 331 insertions(+), 189 deletions(-) create mode 100644 lib/delimiter.ts diff --git a/lib/delimiter.ts b/lib/delimiter.ts new file mode 100644 index 000000000..5a5bbe50a --- /dev/null +++ b/lib/delimiter.ts @@ -0,0 +1,57 @@ +// deno-lint-ignore-file no-unsafe-finally +import { Just, type Maybe, Nothing } from "./maybe.ts"; +import { Err, Ok, type Result } from "./result.ts"; +import type { Coroutine, Operation } from "./types.ts"; +import { withResolvers } from "./with-resolvers.ts"; + +export class Delimiter implements Operation>> { + level = 0; + outcome = withResolvers>>(); + computed = false; + then: (outcome: Maybe>) => void = () => {}; + + constructor( + public readonly operation: () => Operation, + public routine?: Coroutine, + ) {} + + exit(outcome: Maybe>, override = false): void { + if (this.level === 0) { + this.outcome.resolve(outcome); + } else if (override || this.level++ === 1) { + this.routine?.return(Ok(outcome)); + } + } + + *close(): Operation { + let done = this.outcome.operation; + let interrupted = !this.computed; + this.close = function* close() { + let outcome = yield* done; + if (interrupted && outcome.exists && !outcome.value.ok) { + throw outcome.value.error; + } + }; + this.exit(Nothing()); + yield* this.close(); + } + + [Symbol.iterator] = function* delimiter(this: Delimiter) { + let outcome = Nothing>(); + try { + this.level = 1; + let value = yield* this.operation(); + if (this.level === 1) { + this.computed = true; + outcome = Just(Ok(value)); + } + } catch (error) { + this.computed = true; + outcome = Just(Err(error as Error)); + } finally { + this.outcome.resolve(outcome); + this.then(outcome); + return outcome; + } + }; +} diff --git a/lib/task.ts b/lib/task.ts index 5766cdf38..63a44fd5c 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -3,6 +3,7 @@ import { type Boundary, BoundaryContext } from "./boundary.ts"; import { box } from "./box.ts"; import { createContext } from "./context.ts"; import { createCoroutine, useCoroutine } from "./coroutine.ts"; +import { Delimiter } from "./delimiter.ts"; import { createFuture } from "./future.ts"; import { Just, type Maybe, Nothing } from "./maybe.ts"; import { Err, Ok, type Result, unbox } from "./result.ts"; @@ -24,56 +25,23 @@ export interface NewTask { export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; let [scope] = createScopeInternal(owner); - let future = createFuture(); - let finalized = createFuture< - { outcome: Maybe>; teardown: Result } - >(); - - let trap = owner.expect(BoundaryContext); - - scope.set(CrashContext, (trap, error) => { - trap.outcome = Just(Err(error)); - routine.return(Ok()); - }); - - scope.set(BoundaryContext, { outcome: Nothing>(), runLevel: 0 }); - - let group = owner.expect(TaskGroupContext); - let children = scope.set(TaskGroupContext, new TaskGroup()); - - scope.ensure(halt); - - let interrupted = false; - - function* halt(): Operation { - // halt was called before the task could ever run. - if (routine.runLevel === 0) { - routine.runLevel = 3; - finalized.resolve({ outcome: Nothing(), teardown: Ok() }); + let top = new Delimiter(operation); + top.then = (outcome) => { + if (outcome.exists) { + let result = outcome.value; + if (result.ok) { + future.resolve(result.value); + } else { + future.reject(result.error); + } + } else { future.reject(new Error("halted")); - } else if (routine.runLevel === 2) { - //console.log("HALTING LEVEL 2"); - // happens when a child is completing, and waiting for trap safe return - // but parent decides to halt it. - } else if (routine.runLevel < 2) { - interrupted = true; - routine.runLevel = 2; - routine.scope.expect(BoundaryContext).outcome = Nothing(); - routine.return(Ok()); - group.delete(task); } + }; - let { outcome, teardown } = yield* finalized.future; - - if (!teardown.ok) { - throw teardown.error; - } - if (outcome.exists && interrupted && !outcome.value.ok) { - throw outcome.value.error; - } - } + let halt = () => top.close(); let task = Object.defineProperties(future.future, { halt: { @@ -115,158 +83,275 @@ export function createTask(options: TaskOptions): NewTask { }, }) as Task; - group.add(task); - - let routine = createCoroutine({ + let routine = top.routine = createCoroutine({ scope, - *operation() { - routine.runLevel = 1; + operation: () => top, + }); - let outcome = yield* trapsafe(operation); + let start = () => routine.next(Ok()); - group.delete(task); + return { scope, routine, task, start }; +} - scope.set(CrashContext, () => {}); - let crash = owner.expect(CrashContext); +export function* trap(operation: () => Operation): Operation { + let outcome = yield* new Delimiter(operation, yield* useCoroutine()); + if (outcome.exists) { + return unbox(outcome.value); + } else { + throw new Error("TODO: propagate halt"); + } +} - routine.runLevel = 2; +export function* encapsulate(operation: () => Operation): Operation { + return yield* operation(); +} - let teardown = yield* box(() => children.halt()); +// export function createTask(options: TaskOptions): NewTask { +// let { owner, operation } = options; +// let [scope] = createScopeInternal(owner); - routine.runLevel = 3; +// let future = createFuture(); +// let finalized = createFuture< +// { outcome: Maybe>; teardown: Result } +// >(); - finalized.resolve({ outcome, teardown }); +// let trap = owner.expect(BoundaryContext); - if (!teardown.ok) { - future.reject(teardown.error); - crash(trap, teardown.error); - } else { - if (outcome.exists) { - let result = outcome.value; - if (result.ok) { - future.resolve(result.value); - } else { - future.reject(result.error); - crash(trap, result.error); - } - } else { - future.reject(new Error("halted")); - } - } - }, - }); +// scope.set(CrashContext, (trap, error) => { +// trap.outcome = Just(Err(error)); +// routine.return(Ok()); +// }); - let start = () => routine.next(Ok()); +// scope.set(BoundaryContext, { outcome: Nothing>(), runLevel: 0 }); - return { task, scope, routine, start }; -} +// let group = owner.expect(TaskGroupContext); -const CrashContext = createContext< - (trap: Boundary, error: Error) => void ->( - "@effection/crash", - () => {}, -); +// let children = scope.set(TaskGroupContext, new TaskGroup()); -class TaskGroup { - tasks = new Set>(); +// scope.ensure(halt); - add(task: Task) { - this.tasks.add(task); - } +// let interrupted = false; - delete(task: Task) { - this.tasks.delete(task); - } +// function* halt(): Operation { +// // halt was called before the task could ever run. +// if (routine.runLevel === 0) { +// routine.runLevel = 3; +// finalized.resolve({ outcome: Nothing(), teardown: Ok() }); +// future.reject(new Error("halted")); +// } else if (routine.runLevel === 2) { +// //console.log("HALTING LEVEL 2"); +// // happens when a child is completing, and waiting for trap safe return +// // but parent decides to halt it. +// } else if (routine.runLevel < 2) { +// interrupted = true; +// routine.runLevel = 2; +// routine.scope.expect(BoundaryContext).outcome = Nothing(); +// routine.return(Ok()); +// group.delete(task); +// } - *halt(): Operation { - let total = Ok(); - while (this.tasks.size > 0) { - let tasks = [...this.tasks].reverse(); - this.tasks.clear(); - for (let task of tasks) { - let result = yield* box(task.halt); - if (!result.ok) { - total = result; - } - } - } - unbox(total); - } -} +// let { outcome, teardown } = yield* finalized.future; -const TaskGroupContext = createContext( - "@effection/task-group", - new TaskGroup(), -); - -function* trapsafe( - op: () => Operation, -): Operation>> { - let routine = yield* useCoroutine(); - let trap = yield* BoundaryContext.expect(); - let original = trap.outcome; - try { - let value = yield* op(); - if (trap.outcome === original) { - trap.outcome = Just(Ok(value)); - } - } catch (error) { - trap.outcome = Just(Err(error as Error)); - } finally { - routine.runLevel = 2; - return (yield { - description: "trapset return", - enter(resolve) { - resolve(Ok(trap.outcome)); - return (didExit) => didExit(Ok()); - }, - }) as Maybe>; - } -} +// if (!teardown.ok) { +// throw teardown.error; +// } +// if (outcome.exists && interrupted && !outcome.value.ok) { +// throw outcome.value.error; +// } +// } -export function* trap(op: () => Operation): Operation { - let original = yield* BoundaryContext.expect(); - let trap: Boundary = { outcome: Nothing>(), runLevel: 0 }; - try { - yield* BoundaryContext.set(trap); - let value = yield* op(); - trap.outcome = Just(Ok(value)); - } catch (error) { - trap.outcome = Just(Err(error as Error)); - } finally { - yield* BoundaryContext.set(original); - const { outcome } = trap; - - Object.defineProperty(trap, "outcome", { - set(value: Maybe>) { - original.outcome = value; - }, - get() { - return original.outcome; - }, - }); +// let task = Object.defineProperties(future.future, { +// halt: { +// enumerable: false, +// value() { +// return Object.defineProperties(Object.create(Promise.prototype), { +// [Symbol.iterator]: { +// enumerable: false, +// value: halt, +// }, +// then: { +// enumerable: false, +// value(...args: Parameters["then"]>) { +// return owner.run(halt).then(...args); +// }, +// }, +// catch: { +// enumerable: false, +// value(...args: Parameters["catch"]>) { +// return owner.run(halt).catch(...args); +// }, +// }, +// finally: { +// enumerable: false, +// value(...args: Parameters["finally"]>) { +// return owner.run(halt).finally(...args); +// }, +// }, +// }); +// }, +// }, +// [Symbol.iterator]: { +// enumerable: false, +// value: future.future[Symbol.iterator], +// }, +// [Symbol.toStringTag]: { +// enumerable: false, +// value: "Task", +// }, +// }) as Task; - if (outcome.exists) { - const { value: result } = outcome; - if (result.ok) { - return result.value; - } else { - throw result.error; - } - } else { - return (yield { - description: "propagate halt", - enter(_, routine) { - original.outcome = Nothing(); - routine.return(Ok()); - return (didExit) => didExit(Ok()); - }, - }) as T; - } - } -} +// group.add(task); + +// let routine = createCoroutine({ +// scope, +// *operation() { +// routine.runLevel = 1; + +// let outcome = yield* trapsafe(operation); + +// group.delete(task); + +// scope.set(CrashContext, () => {}); +// let crash = owner.expect(CrashContext); + +// routine.runLevel = 2; + +// let teardown = yield* box(() => children.halt()); + +// routine.runLevel = 3; + +// finalized.resolve({ outcome, teardown }); + +// if (!teardown.ok) { +// future.reject(teardown.error); +// crash(trap, teardown.error); +// } else { +// if (outcome.exists) { +// let result = outcome.value; +// if (result.ok) { +// future.resolve(result.value); +// } else { +// future.reject(result.error); +// crash(trap, result.error); +// } +// } else { +// future.reject(new Error("halted")); +// } +// } +// }, +// }); + +// let start = () => routine.next(Ok()); + +// return { task, scope, routine, start }; +// } + +// const CrashContext = createContext< +// (trap: Boundary, error: Error) => void +// >( +// "@effection/crash", +// () => {}, +// ); + +// class TaskGroup { +// tasks = new Set>(); + +// add(task: Task) { +// this.tasks.add(task); +// } + +// delete(task: Task) { +// this.tasks.delete(task); +// } + +// *halt(): Operation { +// let total = Ok(); +// while (this.tasks.size > 0) { +// let tasks = [...this.tasks].reverse(); +// this.tasks.clear(); +// for (let task of tasks) { +// let result = yield* box(task.halt); +// if (!result.ok) { +// total = result; +// } +// } +// } +// unbox(total); +// } +// } + +// const TaskGroupContext = createContext( +// "@effection/task-group", +// new TaskGroup(), +// ); +// function* trapsafe( +// op: () => Operation, +// ): Operation>> { +// let routine = yield* useCoroutine(); +// let trap = yield* BoundaryContext.expect(); +// let original = trap.outcome; +// try { +// let value = yield* op(); +// if (trap.outcome === original) { +// trap.outcome = Just(Ok(value)); +// } +// } catch (error) { +// trap.outcome = Just(Err(error as Error)); +// } finally { +// routine.runLevel = 2; +// return (yield { +// description: "trapset return", +// enter(resolve) { +// resolve(Ok(trap.outcome)); +// return (didExit) => didExit(Ok()); +// }, +// }) as Maybe>; +// } +// } + +// export function* trap(op: () => Operation): Operation { +// let original = yield* BoundaryContext.expect(); +// let trap: Boundary = { outcome: Nothing>(), runLevel: 0 }; +// try { +// yield* BoundaryContext.set(trap); +// let value = yield* op(); +// trap.outcome = Just(Ok(value)); +// } catch (error) { +// trap.outcome = Just(Err(error as Error)); +// } finally { +// yield* BoundaryContext.set(original); +// const { outcome } = trap; + +// Object.defineProperty(trap, "outcome", { +// set(value: Maybe>) { +// original.outcome = value; +// }, +// get() { +// return original.outcome; +// }, +// }); + +// if (outcome.exists) { +// const { value: result } = outcome; +// if (result.ok) { +// return result.value; +// } else { +// throw result.error; +// } +// } else { +// return (yield { +// description: "propagate halt", +// enter(_, routine) { +// original.outcome = Nothing(); +// routine.return(Ok()); +// return (didExit) => didExit(Ok()); +// }, +// }) as T; +// } +// } +// } +// // export function createTask(options: TaskOptions): NewTask { // let { owner, operation } = options; // let [scope] = createScopeInternal(owner); @@ -464,15 +549,15 @@ export function* trap(op: () => Operation): Operation { // } // } -export function encapsulate(op: () => Operation): Operation { - return TaskGroupContext.with(new TaskGroup(), function* (group) { - try { - return yield* op(); - } finally { - yield* group.halt(); - } - }); -} +// export function encapsulate(op: () => Operation): Operation { +// return TaskGroupContext.with(new TaskGroup(), function* (group) { +// try { +// return yield* op(); +// } finally { +// yield* group.halt(); +// } +// }); +// } // export function trap(op: () => Operation): Operation { // return op(); diff --git a/test/run.test.ts b/test/run.test.ts index 5c82e88f0..aa0a9150c 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -186,7 +186,7 @@ describe("run()", () => { expect(things).toEqual(["first", "second"]); }); - it("can be halted while in the generator", async () => { + it.skip("can be halted while in the generator", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -221,7 +221,7 @@ describe("run()", () => { await expect(task).rejects.toMatchObject({ message: "halted" }); }); - it("can delay halt if child fails", async () => { + it.skip("can delay halt if child fails", async () => { let didRun = false; let task = run(function* Main() { yield* spawn(function* willBoom() { @@ -261,7 +261,7 @@ describe("run()", () => { await expect(task.halt()).rejects.toEqual(error); }); - it("can throw error when child blows up", async () => { + it.skip("can throw error when child blows up", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); From 1a4128dc62ce2a9d366e6fd99cb7e7cdb9609ee6 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 4 Jul 2025 16:52:01 +0300 Subject: [PATCH 051/119] Get run tests working --- lib/delimiter.ts | 61 ++++++++++++++++++++++++++++++---------------- lib/task.ts | 63 +++++++++++++++++++++++++++--------------------- test/run.test.ts | 6 ++--- 3 files changed, 78 insertions(+), 52 deletions(-) diff --git a/lib/delimiter.ts b/lib/delimiter.ts index 5a5bbe50a..39133a9c2 100644 --- a/lib/delimiter.ts +++ b/lib/delimiter.ts @@ -1,31 +1,30 @@ // deno-lint-ignore-file no-unsafe-finally +import { createContext } from "./context.ts"; +import { useCoroutine } from "./coroutine.ts"; import { Just, type Maybe, Nothing } from "./maybe.ts"; import { Err, Ok, type Result } from "./result.ts"; import type { Coroutine, Operation } from "./types.ts"; import { withResolvers } from "./with-resolvers.ts"; -export class Delimiter implements Operation>> { +export class Delimiter + implements Operation>>, ErrorBoundary { level = 0; - outcome = withResolvers>>(); + future = withResolvers>>(); computed = false; - then: (outcome: Maybe>) => void = () => {}; + routine?: Coroutine; + outcome?: Maybe>; constructor( public readonly operation: () => Operation, - public routine?: Coroutine, ) {} - exit(outcome: Maybe>, override = false): void { - if (this.level === 0) { - this.outcome.resolve(outcome); - } else if (override || this.level++ === 1) { - this.routine?.return(Ok(outcome)); - } + raise(error: Error): void { + this.exit(Just(Err(error))); } *close(): Operation { - let done = this.outcome.operation; - let interrupted = !this.computed; + let done = this.future.operation; + let interrupted = !this.computed; this.close = function* close() { let outcome = yield* done; if (interrupted && outcome.exists && !outcome.value.ok) { @@ -36,22 +35,42 @@ export class Delimiter implements Operation>> { yield* this.close(); } + private exit(outcome: Maybe>): void { + this.outcome = outcome; + if (!this.routine) { + this.future.resolve(outcome); + } else { + this.level++; + this.routine.return(Ok(outcome)); + } + } + [Symbol.iterator] = function* delimiter(this: Delimiter) { - let outcome = Nothing>(); + this.routine = yield* useCoroutine(); try { - this.level = 1; let value = yield* this.operation(); - if (this.level === 1) { - this.computed = true; - outcome = Just(Ok(value)); + if (this.level === 0) { + this.computed = true; + this.outcome = Just(Ok(value)); } } catch (error) { this.computed = true; - outcome = Just(Err(error as Error)); + this.outcome = Just(Err(error as Error)); } finally { - this.outcome.resolve(outcome); - this.then(outcome); - return outcome; + this.outcome = this.outcome ?? Nothing(); + this.future.resolve(this.outcome); + return this.outcome; } }; } + +export interface ErrorBoundary { + raise(error: Error): void; +} + +export const ErrorContext = createContext( + "@effection/boundary", + { + raise: () => {}, + }, +); diff --git a/lib/task.ts b/lib/task.ts index 63a44fd5c..e238c123b 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,12 +1,9 @@ -// deno-lint-ignore-file no-unsafe-finally -import { type Boundary, BoundaryContext } from "./boundary.ts"; -import { box } from "./box.ts"; -import { createContext } from "./context.ts"; -import { createCoroutine, useCoroutine } from "./coroutine.ts"; +import { ErrorContext } from "./delimiter.ts"; +import { createCoroutine } from "./coroutine.ts"; import { Delimiter } from "./delimiter.ts"; import { createFuture } from "./future.ts"; -import { Just, type Maybe, Nothing } from "./maybe.ts"; -import { Err, Ok, type Result, unbox } from "./result.ts"; +import { Nothing } from "./maybe.ts"; +import { Ok, type Result, unbox } from "./result.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; import type { Coroutine, Operation, Scope, Task } from "./types.ts"; @@ -28,18 +25,10 @@ export function createTask(options: TaskOptions): NewTask { let future = createFuture(); let top = new Delimiter(operation); - top.then = (outcome) => { - if (outcome.exists) { - let result = outcome.value; - if (result.ok) { - future.resolve(result.value); - } else { - future.reject(result.error); - } - } else { - future.reject(new Error("halted")); - } - }; + + let boundary = owner.expect(ErrorContext); + + scope.set(ErrorContext, top); let halt = () => top.close(); @@ -85,7 +74,25 @@ export function createTask(options: TaskOptions): NewTask { let routine = top.routine = createCoroutine({ scope, - operation: () => top, + *operation() { + try { + yield* top; + } finally { + let { outcome } = top; + if (outcome!.exists) { + let result = outcome!.value; + if (result.ok) { + future.resolve(result.value); + } else { + let { error } = result; + future.reject(error); + boundary.raise(error) + } + } else { + future.reject(new Error("halted")); + } + } + }, }); let start = () => routine.next(Ok()); @@ -94,7 +101,7 @@ export function createTask(options: TaskOptions): NewTask { } export function* trap(operation: () => Operation): Operation { - let outcome = yield* new Delimiter(operation, yield* useCoroutine()); + let outcome = yield* new Delimiter(operation); if (outcome.exists) { return unbox(outcome.value); } else { @@ -115,14 +122,14 @@ export function* encapsulate(operation: () => Operation): Operation { // { outcome: Maybe>; teardown: Result } // >(); -// let trap = owner.expect(BoundaryContext); +// let trap = owner.expect(ErrorContext); // scope.set(CrashContext, (trap, error) => { // trap.outcome = Just(Err(error)); // routine.return(Ok()); // }); -// scope.set(BoundaryContext, { outcome: Nothing>(), runLevel: 0 }); +// scope.set(ErrorContext, { outcome: Nothing>(), runLevel: 0 }); // let group = owner.expect(TaskGroupContext); @@ -145,7 +152,7 @@ export function* encapsulate(operation: () => Operation): Operation { // } else if (routine.runLevel < 2) { // interrupted = true; // routine.runLevel = 2; -// routine.scope.expect(BoundaryContext).outcome = Nothing(); +// routine.scope.expect(ErrorContext).outcome = Nothing(); // routine.return(Ok()); // group.delete(task); // } @@ -289,7 +296,7 @@ export function* encapsulate(operation: () => Operation): Operation { // op: () => Operation, // ): Operation>> { // let routine = yield* useCoroutine(); -// let trap = yield* BoundaryContext.expect(); +// let trap = yield* ErrorContext.expect(); // let original = trap.outcome; // try { // let value = yield* op(); @@ -311,16 +318,16 @@ export function* encapsulate(operation: () => Operation): Operation { // } // export function* trap(op: () => Operation): Operation { -// let original = yield* BoundaryContext.expect(); +// let original = yield* ErrorContext.expect(); // let trap: Boundary = { outcome: Nothing>(), runLevel: 0 }; // try { -// yield* BoundaryContext.set(trap); +// yield* ErrorContext.set(trap); // let value = yield* op(); // trap.outcome = Just(Ok(value)); // } catch (error) { // trap.outcome = Just(Err(error as Error)); // } finally { -// yield* BoundaryContext.set(original); +// yield* ErrorContext.set(original); // const { outcome } = trap; // Object.defineProperty(trap, "outcome", { diff --git a/test/run.test.ts b/test/run.test.ts index aa0a9150c..5c82e88f0 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -186,7 +186,7 @@ describe("run()", () => { expect(things).toEqual(["first", "second"]); }); - it.skip("can be halted while in the generator", async () => { + it("can be halted while in the generator", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); @@ -221,7 +221,7 @@ describe("run()", () => { await expect(task).rejects.toMatchObject({ message: "halted" }); }); - it.skip("can delay halt if child fails", async () => { + it("can delay halt if child fails", async () => { let didRun = false; let task = run(function* Main() { yield* spawn(function* willBoom() { @@ -261,7 +261,7 @@ describe("run()", () => { await expect(task.halt()).rejects.toEqual(error); }); - it.skip("can throw error when child blows up", async () => { + it("can throw error when child blows up", async () => { let task = run(function* Main() { yield* spawn(function* Boomer() { throw new Error("boom"); From 234dc6ada0b4c13bc25e5d2b85e2b7b335195850 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 4 Jul 2025 16:57:56 +0300 Subject: [PATCH 052/119] Get ready for spawn tests --- test/spawn.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/spawn.test.ts b/test/spawn.test.ts index 0d081eae7..02e97e2a7 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -15,7 +15,7 @@ describe("spawn", () => { await expect(root).resolves.toEqual(67); }); - it("halts child when halted", async () => { + it.skip("halts child when halted", async () => { let child; let root = run(function* root() { child = yield* spawn(function* child() { @@ -30,7 +30,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it("halts child when finishing normally", async () => { + it.skip("halts child when finishing normally", async () => { let child; let result = run(function* parent() { child = yield* spawn(function* () { @@ -46,7 +46,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it("halts child when errored", async () => { + it.skip("halts child when errored", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -77,7 +77,7 @@ describe("spawn", () => { await expect(child).rejects.toEqual(error); }); - it("finishes normally when child halts", async () => { + it.skip("finishes normally when child halts", async () => { let child; let root = run(function* () { child = yield* spawn(() => suspend()); @@ -90,7 +90,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it("rejects when child errors during completing", async () => { + it.skip("rejects when child errors during completing", async () => { let root = run(function* root() { yield* spawn(function* child() { try { @@ -106,7 +106,7 @@ describe("spawn", () => { await expect(root).rejects.toHaveProperty("message", "moo"); }); - it("rejects when child errors during halting", async () => { + it.skip("rejects when child errors during halting", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -144,7 +144,7 @@ describe("spawn", () => { expect(didFinish).toEqual(true); }); - it("runs destructors in reverse order and in series", async () => { + it.skip("runs destructors in reverse order and in series", async () => { let result: string[] = []; await run(function* () { @@ -177,7 +177,7 @@ describe("spawn", () => { ]); }); - it("halts children on explicit halt", async () => { + it.skip("halts children on explicit halt", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { From 223cb090f7e861f7822b6c9b93a1fe7792c8c199 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 4 Jul 2025 17:19:35 +0300 Subject: [PATCH 053/119] implement task group to start fixing spawn tests --- lib/callcc.ts | 2 +- lib/task-group.ts | 46 +++++++++++++++++++++++++++++++ lib/task.ts | 31 +++++++++++---------- tasks/bench/scenarios/scenario.ts | 2 +- test/spawn.test.ts | 12 ++++---- 5 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 lib/task-group.ts diff --git a/lib/callcc.ts b/lib/callcc.ts index 606fdd022..5d969c95a 100644 --- a/lib/callcc.ts +++ b/lib/callcc.ts @@ -3,7 +3,7 @@ import { Err, Ok, type Result, unbox } from "./result.ts"; import type { Operation } from "./types.ts"; import { withResolvers } from "./with-resolvers.ts"; import { spawn } from "./spawn.ts"; -import { encapsulate } from "./task.ts"; +import { encapsulate } from "./task-group.ts"; export function* callcc( op: ( diff --git a/lib/task-group.ts b/lib/task-group.ts new file mode 100644 index 000000000..70f445345 --- /dev/null +++ b/lib/task-group.ts @@ -0,0 +1,46 @@ +import { createContext } from "./context.ts"; +import { box } from "./box.ts"; +import { Ok, unbox } from "./result.ts"; +import type { Operation, Task } from "./types.ts"; + +export class TaskGroup { + tasks = new Set>(); + + add(task: Task) { + this.tasks.add(task); + } + + delete(task: Task) { + this.tasks.delete(task); + } + + *halt(): Operation { + let total = Ok(); + while (this.tasks.size > 0) { + let tasks = [...this.tasks].reverse(); + this.tasks.clear(); + for (let task of tasks) { + let result = yield* box(task.halt); + if (!result.ok) { + total = result; + } + } + } + unbox(total); + } +} + +export const TaskGroupContext = createContext( + "@effection/task-group", + new TaskGroup(), +); + +export function encapsulate(operation: () => Operation): Operation { + return TaskGroupContext.with(new TaskGroup(), function*(group) { + try { + return yield* operation(); + } finally { + yield* group.halt(); + } + }) +} diff --git a/lib/task.ts b/lib/task.ts index e238c123b..0ca4015d6 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -6,6 +6,7 @@ import { Nothing } from "./maybe.ts"; import { Ok, type Result, unbox } from "./result.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; import type { Coroutine, Operation, Scope, Task } from "./types.ts"; +import { encapsulate, TaskGroupContext } from "./task-group.ts"; export interface TaskOptions { owner: ScopeInternal; @@ -24,11 +25,7 @@ export function createTask(options: TaskOptions): NewTask { let [scope] = createScopeInternal(owner); let future = createFuture(); - let top = new Delimiter(operation); - - let boundary = owner.expect(ErrorContext); - - scope.set(ErrorContext, top); + let top = new Delimiter(() => encapsulate(operation)); let halt = () => top.close(); @@ -72,25 +69,33 @@ export function createTask(options: TaskOptions): NewTask { }, }) as Task; - let routine = top.routine = createCoroutine({ + let boundary = owner.expect(ErrorContext); + + scope.set(ErrorContext, top); + + let group = scope.expect(TaskGroupContext); + + let routine = createCoroutine({ scope, *operation() { try { - yield* top; + group.add(task); + yield* top; } finally { - let { outcome } = top; + group.delete(task); + let { outcome } = top; if (outcome!.exists) { let result = outcome!.value; if (result.ok) { future.resolve(result.value); } else { - let { error } = result; + let { error } = result; future.reject(error); - boundary.raise(error) + boundary.raise(error); } } else { future.reject(new Error("halted")); - } + } } }, }); @@ -109,10 +114,6 @@ export function* trap(operation: () => Operation): Operation { } } -export function* encapsulate(operation: () => Operation): Operation { - return yield* operation(); -} - // export function createTask(options: TaskOptions): NewTask { // let { owner, operation } = options; // let [scope] = createScopeInternal(owner); diff --git a/tasks/bench/scenarios/scenario.ts b/tasks/bench/scenarios/scenario.ts index 3b50b9aee..ce020576c 100644 --- a/tasks/bench/scenarios/scenario.ts +++ b/tasks/bench/scenarios/scenario.ts @@ -1,6 +1,6 @@ import { callcc } from "../../../lib/callcc.ts"; import { Err, Ok } from "../../../lib/result.ts"; -import { encapsulate } from "../../../lib/task.ts"; +import { encapsulate } from "../../../lib/task-group.ts"; import { createChannel, each, diff --git a/test/spawn.test.ts b/test/spawn.test.ts index 02e97e2a7..ce7bdd38d 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -15,7 +15,7 @@ describe("spawn", () => { await expect(root).resolves.toEqual(67); }); - it.skip("halts child when halted", async () => { + it("halts child when halted", async () => { let child; let root = run(function* root() { child = yield* spawn(function* child() { @@ -30,7 +30,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it.skip("halts child when finishing normally", async () => { + it("halts child when finishing normally", async () => { let child; let result = run(function* parent() { child = yield* spawn(function* () { @@ -46,7 +46,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it.skip("halts child when errored", async () => { + it("halts child when errored", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -90,7 +90,7 @@ describe("spawn", () => { await expect(child).rejects.toHaveProperty("message", "halted"); }); - it.skip("rejects when child errors during completing", async () => { + it("rejects when child errors during completing", async () => { let root = run(function* root() { yield* spawn(function* child() { try { @@ -106,7 +106,7 @@ describe("spawn", () => { await expect(root).rejects.toHaveProperty("message", "moo"); }); - it.skip("rejects when child errors during halting", async () => { + it("rejects when child errors during halting", async () => { let child; let root = run(function* () { child = yield* spawn(function* () { @@ -144,7 +144,7 @@ describe("spawn", () => { expect(didFinish).toEqual(true); }); - it.skip("runs destructors in reverse order and in series", async () => { + it("runs destructors in reverse order and in series", async () => { let result: string[] = []; await run(function* () { From a6b4373503a2d27cbe28a3c8707180f65af23328 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 7 Jul 2025 16:05:21 +0300 Subject: [PATCH 054/119] unify run levels with a `validate()` function with each instruction --- lib/coroutine.ts | 14 +++----------- lib/delimiter.ts | 14 +++++++++++++- lib/reducer.ts | 15 ++++----------- lib/task.ts | 13 ++++++++----- lib/types.ts | 1 - test/spawn.test.ts | 8 ++++---- 6 files changed, 32 insertions(+), 33 deletions(-) diff --git a/lib/coroutine.ts b/lib/coroutine.ts index 6bb623602..f00d16fe5 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -1,5 +1,5 @@ -import { BoundaryContext } from "./boundary.ts"; import { Generation } from "./contexts.ts"; +import { DelimiterContext } from "./delimiter.ts"; import { ReducerContext } from "./reducer.ts"; import { Ok } from "./result.ts"; import type { Coroutine, Operation, Scope } from "./types.ts"; @@ -31,32 +31,24 @@ export function createCoroutine( next(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); - const boundary = routine.scope.expect(BoundaryContext); reducer.reduce([ scope.expect(Generation), routine, exitResult.ok ? result : exitResult, - () => {}, + scope.expect(DelimiterContext).validator, "next", - routine.runLevel, - boundary, - boundary.runLevel, ]); }); }, return(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); - const boundary = routine.scope.expect(BoundaryContext); reducer.reduce([ scope.expect(Generation), routine, exitResult.ok ? result : exitResult, - () => {}, + scope.expect(DelimiterContext).validator, "return", - routine.runLevel, - boundary, - boundary.runLevel, ]); }); }, diff --git a/lib/delimiter.ts b/lib/delimiter.ts index 39133a9c2..522a4bbd6 100644 --- a/lib/delimiter.ts +++ b/lib/delimiter.ts @@ -9,6 +9,7 @@ import { withResolvers } from "./with-resolvers.ts"; export class Delimiter implements Operation>>, ErrorBoundary { level = 0; + finalized = false; future = withResolvers>>(); computed = false; routine?: Coroutine; @@ -37,14 +38,20 @@ export class Delimiter private exit(outcome: Maybe>): void { this.outcome = outcome; + this.level++; if (!this.routine) { + this.finalized = true; this.future.resolve(outcome); } else { - this.level++; this.routine.return(Ok(outcome)); } } + get validator(): () => boolean { + let { level } = this; + return () => !this.finalized && this.level === level; + } + [Symbol.iterator] = function* delimiter(this: Delimiter) { this.routine = yield* useCoroutine(); try { @@ -58,12 +65,17 @@ export class Delimiter this.outcome = Just(Err(error as Error)); } finally { this.outcome = this.outcome ?? Nothing(); + this.finalized = true; this.future.resolve(this.outcome); return this.outcome; } }; } +export const DelimiterContext = createContext>( + "@effection/delimiter", +); + export interface ErrorBoundary { raise(error: Error): void; } diff --git a/lib/reducer.ts b/lib/reducer.ts index cdbe7051a..affe7dc5d 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -1,5 +1,6 @@ import { type Boundary, BoundaryContext } from "./boundary.ts"; import { createContext } from "./context.ts"; +import { DelimiterContext } from "./delimiter.ts"; import { PriorityQueue } from "./priority-queue.ts"; import { Err, type Result } from "./result.ts"; import type { Coroutine } from "./types.ts"; @@ -63,11 +64,8 @@ type Instruction = [ number, Coroutine, Result, - () => void, + () => boolean, "return" | "next", - number, - Boundary, - number, ]; class InstructionQueue extends PriorityQueue { @@ -80,14 +78,9 @@ class InstructionQueue extends PriorityQueue { let top = this.pop(); if (!top) { return undefined; - } else if (top[5] < top[1].runLevel) { - continue; } else { - let coroutine = top[1]; - let boundary = top[6]; - let level = top[7]; - let current = coroutine.scope.expect(BoundaryContext); - if (current !== boundary || current.runLevel !== level) { + let validate = top[3]; + if (!validate()) { continue; } return top; diff --git a/lib/task.ts b/lib/task.ts index 0ca4015d6..3a58ff90c 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,9 +1,8 @@ -import { ErrorContext } from "./delimiter.ts"; +import { DelimiterContext, ErrorContext } from "./delimiter.ts"; import { createCoroutine } from "./coroutine.ts"; import { Delimiter } from "./delimiter.ts"; import { createFuture } from "./future.ts"; -import { Nothing } from "./maybe.ts"; -import { Ok, type Result, unbox } from "./result.ts"; +import { Ok, unbox } from "./result.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; import type { Coroutine, Operation, Scope, Task } from "./types.ts"; import { encapsulate, TaskGroupContext } from "./task-group.ts"; @@ -26,8 +25,12 @@ export function createTask(options: TaskOptions): NewTask { let future = createFuture(); let top = new Delimiter(() => encapsulate(operation)); + scope.set(DelimiterContext, top as Delimiter); - let halt = () => top.close(); + let halt = function* halt() { + yield* top.close(); + future.reject(new Error("halted")); + } let task = Object.defineProperties(future.future, { halt: { @@ -74,12 +77,12 @@ export function createTask(options: TaskOptions): NewTask { scope.set(ErrorContext, top); let group = scope.expect(TaskGroupContext); + group.add(task); let routine = createCoroutine({ scope, *operation() { try { - group.add(task); yield* top; } finally { group.delete(task); diff --git a/lib/types.ts b/lib/types.ts index ea6268e03..34752bb6c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -355,7 +355,6 @@ export interface Effect { * @ignore */ export interface Coroutine { - runLevel: 0 | 1 | 2 | 3; scope: Scope; data: { exit(resolve: Resolve>): void; diff --git a/test/spawn.test.ts b/test/spawn.test.ts index ce7bdd38d..5b6fbb379 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -77,7 +77,7 @@ describe("spawn", () => { await expect(child).rejects.toEqual(error); }); - it.skip("finishes normally when child halts", async () => { + it("finishes normally when child halts", async () => { let child; let root = run(function* () { child = yield* spawn(() => suspend()); @@ -177,9 +177,9 @@ describe("spawn", () => { ]); }); - it.skip("halts children on explicit halt", async () => { + it("halts children on explicit halt", async () => { let child; - let root = run(function* () { + let value = await run(function* () { child = yield* spawn(function* () { yield* sleep(2); return "foo"; @@ -188,7 +188,7 @@ describe("spawn", () => { return 1; }); - await root.halt(); + expect(value).toEqual(1); await expect(child).rejects.toHaveProperty("message", "halted"); }); From 31598651e09d1a2fe2c4fe2e5ea39146167fcd27 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 7 Jul 2025 16:07:06 +0300 Subject: [PATCH 055/119] halt a task whenever its scope is halted --- lib/task.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/task.ts b/lib/task.ts index 3a58ff90c..19ceb7c01 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -32,6 +32,8 @@ export function createTask(options: TaskOptions): NewTask { future.reject(new Error("halted")); } + scope.ensure(halt); + let task = Object.defineProperties(future.future, { halt: { enumerable: false, From 937f92ec1cbea100ab4ec6514682ea940db336c5 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 7 Jul 2025 20:53:28 +0300 Subject: [PATCH 056/119] All tests green! --- lib/boundary.ts | 16 -------------- lib/delimiter.ts | 20 ++++++++++++++--- lib/reducer.ts | 4 +--- lib/scoped.ts | 2 +- lib/task-group.ts | 4 ++-- lib/task.ts | 51 +++++++++++++++++++++++++++++++++++++------ test/resource.test.ts | 4 ++-- test/scoped.test.ts | 1 + 8 files changed, 68 insertions(+), 34 deletions(-) delete mode 100644 lib/boundary.ts diff --git a/lib/boundary.ts b/lib/boundary.ts deleted file mode 100644 index 7947f48de..000000000 --- a/lib/boundary.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext } from "./context.ts"; -import { Just, type Maybe } from "./maybe.ts"; -import { Err, type Result } from "./result.ts"; - -export interface Boundary { - outcome: Maybe>; - runLevel: 0; -} - -export const BoundaryContext = createContext>( - "@effection/trap", - { - outcome: Just(Err(new Error("unbounded context"))), - runLevel: 0, - }, -); diff --git a/lib/delimiter.ts b/lib/delimiter.ts index 522a4bbd6..a9cd804af 100644 --- a/lib/delimiter.ts +++ b/lib/delimiter.ts @@ -17,10 +17,20 @@ export class Delimiter constructor( public readonly operation: () => Operation, + public readonly parent?: Delimiter, ) {} raise(error: Error): void { - this.exit(Just(Err(error))); + let failure = Just(Err(error)); + if (this.finalized) { + this.parent?.exit(failure); + } else { + this.exit(failure); + } + } + + interrupt(): void { + this.exit(Nothing()); } *close(): Operation { @@ -32,11 +42,14 @@ export class Delimiter throw outcome.value.error; } }; - this.exit(Nothing()); + this.interrupt(); yield* this.close(); } private exit(outcome: Maybe>): void { + if (this.finalized) { + return; + } this.outcome = outcome; this.level++; if (!this.routine) { @@ -54,6 +67,7 @@ export class Delimiter [Symbol.iterator] = function* delimiter(this: Delimiter) { this.routine = yield* useCoroutine(); + try { let value = yield* this.operation(); if (this.level === 0) { @@ -64,8 +78,8 @@ export class Delimiter this.computed = true; this.outcome = Just(Err(error as Error)); } finally { - this.outcome = this.outcome ?? Nothing(); this.finalized = true; + this.outcome = this.outcome ?? Nothing(); this.future.resolve(this.outcome); return this.outcome; } diff --git a/lib/reducer.ts b/lib/reducer.ts index affe7dc5d..cc49be1cb 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -1,6 +1,4 @@ -import { type Boundary, BoundaryContext } from "./boundary.ts"; import { createContext } from "./context.ts"; -import { DelimiterContext } from "./delimiter.ts"; import { PriorityQueue } from "./priority-queue.ts"; import { Err, type Result } from "./result.ts"; import type { Coroutine } from "./types.ts"; @@ -79,7 +77,7 @@ class InstructionQueue extends PriorityQueue { if (!top) { return undefined; } else { - let validate = top[3]; + let validate = top[3]; if (!validate()) { continue; } diff --git a/lib/scoped.ts b/lib/scoped.ts index 3bd66731d..a60d1bdcb 100644 --- a/lib/scoped.ts +++ b/lib/scoped.ts @@ -27,7 +27,7 @@ import { createScopeInternal } from "./scope-internal.ts"; */ export function scoped(operation: () => Operation): Operation { return { - *[Symbol.iterator]() { + [Symbol.iterator]: function* scoped() { let routine = yield* useCoroutine(); let original = routine.scope; let [scope, destroy] = createScopeInternal(original); diff --git a/lib/task-group.ts b/lib/task-group.ts index 70f445345..cbabbc9b3 100644 --- a/lib/task-group.ts +++ b/lib/task-group.ts @@ -36,11 +36,11 @@ export const TaskGroupContext = createContext( ); export function encapsulate(operation: () => Operation): Operation { - return TaskGroupContext.with(new TaskGroup(), function*(group) { + return TaskGroupContext.with(new TaskGroup(), function* (group) { try { return yield* operation(); } finally { yield* group.halt(); } - }) + }); } diff --git a/lib/task.ts b/lib/task.ts index 19ceb7c01..e2eec4b13 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,11 +1,13 @@ +// deno-lint-ignore-file no-unsafe-finally import { DelimiterContext, ErrorContext } from "./delimiter.ts"; import { createCoroutine } from "./coroutine.ts"; import { Delimiter } from "./delimiter.ts"; import { createFuture } from "./future.ts"; -import { Ok, unbox } from "./result.ts"; +import { Ok } from "./result.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; import type { Coroutine, Operation, Scope, Task } from "./types.ts"; import { encapsulate, TaskGroupContext } from "./task-group.ts"; +import { useScope } from "./scope.ts"; export interface TaskOptions { owner: ScopeInternal; @@ -30,7 +32,7 @@ export function createTask(options: TaskOptions): NewTask { let halt = function* halt() { yield* top.close(); future.reject(new Error("halted")); - } + }; scope.ensure(halt); @@ -111,11 +113,46 @@ export function createTask(options: TaskOptions): NewTask { } export function* trap(operation: () => Operation): Operation { - let outcome = yield* new Delimiter(operation); - if (outcome.exists) { - return unbox(outcome.value); - } else { - throw new Error("TODO: propagate halt"); + let scope = yield* useScope(); + + let original = { + error: scope.expect(ErrorContext), + delimiter: scope.expect(DelimiterContext), + }; + + let delimiter = new Delimiter(operation, original.delimiter); + + scope.set(ErrorContext, delimiter); + scope.set(DelimiterContext, delimiter as Delimiter); + try { + yield* delimiter; + } finally { + scope.set(ErrorContext, original.error); + scope.set(DelimiterContext, original.delimiter); + let outcome = delimiter.outcome!; + return (yield { + description: "trap return", + enter(resolve) { + if (outcome.exists) { + resolve(outcome.value); + } else { + original.delimiter.interrupt(); + } + return (didExit) => didExit(Ok()); + }, + }) as T; + + // console.log("", outcome); + // if (outcome.exists) { + // let result = outcome.value; + // if (result.ok) { + // return result.value; + // } else { + // throw result.error; + // } + // } else { + // throw new TypeError(`TODO: propagate halt`); + // } } } diff --git a/test/resource.test.ts b/test/resource.test.ts index e0ac3c6dd..631b77b53 100644 --- a/test/resource.test.ts +++ b/test/resource.test.ts @@ -35,7 +35,7 @@ describe("resource", () => { }); it("can catch an error in init", async () => { - let task = run(function* () { + let result = await run(function* () { try { yield* resource(function* () { throw new Error("moo"); @@ -45,7 +45,7 @@ describe("resource", () => { } }); - await expect(task).resolves.toMatchObject({ message: "moo" }); + expect(result).toMatchObject({ message: "moo" }); }); it("raises an error if an error occurs after init", async () => { diff --git a/test/scoped.test.ts b/test/scoped.test.ts index 1628f1dc1..c79b57c6a 100644 --- a/test/scoped.test.ts +++ b/test/scoped.test.ts @@ -124,6 +124,7 @@ describe("scoped", () => { }); yield* provide(); }); + yield* suspend(); }); } catch (error) { From e862aa2aea81165c9690e56799ef77c7fe4a52e1 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 8 Jul 2025 11:13:03 +0300 Subject: [PATCH 057/119] remove old implementation --- lib/task.ts | 697 ---------------------------------------------------- 1 file changed, 697 deletions(-) diff --git a/lib/task.ts b/lib/task.ts index e2eec4b13..bc956374a 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -141,702 +141,5 @@ export function* trap(operation: () => Operation): Operation { return (didExit) => didExit(Ok()); }, }) as T; - - // console.log("", outcome); - // if (outcome.exists) { - // let result = outcome.value; - // if (result.ok) { - // return result.value; - // } else { - // throw result.error; - // } - // } else { - // throw new TypeError(`TODO: propagate halt`); - // } } } - -// export function createTask(options: TaskOptions): NewTask { -// let { owner, operation } = options; -// let [scope] = createScopeInternal(owner); - -// let future = createFuture(); -// let finalized = createFuture< -// { outcome: Maybe>; teardown: Result } -// >(); - -// let trap = owner.expect(ErrorContext); - -// scope.set(CrashContext, (trap, error) => { -// trap.outcome = Just(Err(error)); -// routine.return(Ok()); -// }); - -// scope.set(ErrorContext, { outcome: Nothing>(), runLevel: 0 }); - -// let group = owner.expect(TaskGroupContext); - -// let children = scope.set(TaskGroupContext, new TaskGroup()); - -// scope.ensure(halt); - -// let interrupted = false; - -// function* halt(): Operation { -// // halt was called before the task could ever run. -// if (routine.runLevel === 0) { -// routine.runLevel = 3; -// finalized.resolve({ outcome: Nothing(), teardown: Ok() }); -// future.reject(new Error("halted")); -// } else if (routine.runLevel === 2) { -// //console.log("HALTING LEVEL 2"); -// // happens when a child is completing, and waiting for trap safe return -// // but parent decides to halt it. -// } else if (routine.runLevel < 2) { -// interrupted = true; -// routine.runLevel = 2; -// routine.scope.expect(ErrorContext).outcome = Nothing(); -// routine.return(Ok()); -// group.delete(task); -// } - -// let { outcome, teardown } = yield* finalized.future; - -// if (!teardown.ok) { -// throw teardown.error; -// } -// if (outcome.exists && interrupted && !outcome.value.ok) { -// throw outcome.value.error; -// } -// } - -// let task = Object.defineProperties(future.future, { -// halt: { -// enumerable: false, -// value() { -// return Object.defineProperties(Object.create(Promise.prototype), { -// [Symbol.iterator]: { -// enumerable: false, -// value: halt, -// }, -// then: { -// enumerable: false, -// value(...args: Parameters["then"]>) { -// return owner.run(halt).then(...args); -// }, -// }, -// catch: { -// enumerable: false, -// value(...args: Parameters["catch"]>) { -// return owner.run(halt).catch(...args); -// }, -// }, -// finally: { -// enumerable: false, -// value(...args: Parameters["finally"]>) { -// return owner.run(halt).finally(...args); -// }, -// }, -// }); -// }, -// }, -// [Symbol.iterator]: { -// enumerable: false, -// value: future.future[Symbol.iterator], -// }, -// [Symbol.toStringTag]: { -// enumerable: false, -// value: "Task", -// }, -// }) as Task; - -// group.add(task); - -// let routine = createCoroutine({ -// scope, -// *operation() { -// routine.runLevel = 1; - -// let outcome = yield* trapsafe(operation); - -// group.delete(task); - -// scope.set(CrashContext, () => {}); -// let crash = owner.expect(CrashContext); - -// routine.runLevel = 2; - -// let teardown = yield* box(() => children.halt()); - -// routine.runLevel = 3; - -// finalized.resolve({ outcome, teardown }); - -// if (!teardown.ok) { -// future.reject(teardown.error); -// crash(trap, teardown.error); -// } else { -// if (outcome.exists) { -// let result = outcome.value; -// if (result.ok) { -// future.resolve(result.value); -// } else { -// future.reject(result.error); -// crash(trap, result.error); -// } -// } else { -// future.reject(new Error("halted")); -// } -// } -// }, -// }); - -// let start = () => routine.next(Ok()); - -// return { task, scope, routine, start }; -// } - -// const CrashContext = createContext< -// (trap: Boundary, error: Error) => void -// >( -// "@effection/crash", -// () => {}, -// ); - -// class TaskGroup { -// tasks = new Set>(); - -// add(task: Task) { -// this.tasks.add(task); -// } - -// delete(task: Task) { -// this.tasks.delete(task); -// } - -// *halt(): Operation { -// let total = Ok(); -// while (this.tasks.size > 0) { -// let tasks = [...this.tasks].reverse(); -// this.tasks.clear(); -// for (let task of tasks) { -// let result = yield* box(task.halt); -// if (!result.ok) { -// total = result; -// } -// } -// } -// unbox(total); -// } -// } - -// const TaskGroupContext = createContext( -// "@effection/task-group", -// new TaskGroup(), -// ); - -// function* trapsafe( -// op: () => Operation, -// ): Operation>> { -// let routine = yield* useCoroutine(); -// let trap = yield* ErrorContext.expect(); -// let original = trap.outcome; -// try { -// let value = yield* op(); -// if (trap.outcome === original) { -// trap.outcome = Just(Ok(value)); -// } -// } catch (error) { -// trap.outcome = Just(Err(error as Error)); -// } finally { -// routine.runLevel = 2; -// return (yield { -// description: "trapset return", -// enter(resolve) { -// resolve(Ok(trap.outcome)); -// return (didExit) => didExit(Ok()); -// }, -// }) as Maybe>; -// } -// } - -// export function* trap(op: () => Operation): Operation { -// let original = yield* ErrorContext.expect(); -// let trap: Boundary = { outcome: Nothing>(), runLevel: 0 }; -// try { -// yield* ErrorContext.set(trap); -// let value = yield* op(); -// trap.outcome = Just(Ok(value)); -// } catch (error) { -// trap.outcome = Just(Err(error as Error)); -// } finally { -// yield* ErrorContext.set(original); -// const { outcome } = trap; - -// Object.defineProperty(trap, "outcome", { -// set(value: Maybe>) { -// original.outcome = value; -// }, -// get() { -// return original.outcome; -// }, -// }); - -// if (outcome.exists) { -// const { value: result } = outcome; -// if (result.ok) { -// return result.value; -// } else { -// throw result.error; -// } -// } else { -// return (yield { -// description: "propagate halt", -// enter(_, routine) { -// original.outcome = Nothing(); -// routine.return(Ok()); -// return (didExit) => didExit(Ok()); -// }, -// }) as T; -// } -// } -// } -// -// export function createTask(options: TaskOptions): NewTask { -// let { owner, operation } = options; -// let [scope] = createScopeInternal(owner); - -// let link = owner.expect(TaskLinkContext); -// let children = new TaskTree((error) => { -// let trap = routine.scope.expect(TrapContext); -// trap.outcome = Just(Err(error)); -// routine.return(Ok()); -// }); -// scope.set(TaskLinkContext, children); -// scope.ensure(() => task.halt()); - -// let state: { current: TaskState } = { -// current: { status: "pending", halted: false }, -// }; - -// let future = createFuture(); - -// let halted = createFuture(); - -// let halt = () => { -// halt = () => {}; -// routine.runLevel = 1; -// halted.resolve(); -// future.reject(new Error("halted")); -// }; - -// scope.set(TrapContext, { -// outcome: Nothing>(), -// }); - -// let routine = createCoroutine({ -// scope, -// *operation() { -// routine.runLevel = 1; -// halt = () => { -// halt = () => {}; -// routine.runLevel = 2; -// state.current.halted = true; -// let trap = scope.expect(TrapContext); -// trap.outcome = Nothing(); -// routine.return(Ok()); -// }; - -// let trap = scope.expect(TrapContext) as Trap; - -// let outcome = yield* trapset(trap, operation); - -// let finalization = -// state.current.halted && outcome.exists && !outcome.value.ok -// ? outcome.value -// : Ok(); - -// children.linked = false; - -// let destruction = yield* box(() => children.destroy()); - -// finalization = !destruction.ok ? destruction : finalization; - -// link.finalized(task, outcome, finalization); - -// if (!state.current.halted) { -// halted.resolve(); -// if (!finalization.ok) { -// future.reject(finalization.error); -// } else if (outcome.exists) { -// const { value: result } = outcome; -// if (result.ok) { -// future.resolve(result.value); -// } else { -// future.reject(result.error); -// } -// } else { -// future.reject(new Error("halted")); -// } -// } else { -// future.reject(new Error("halted")); -// if (finalization.ok) { -// halted.resolve(); -// } else { -// halted.reject(finalization.error); -// } -// } -// }, -// }); - -// let task = Object.defineProperties(future.future, { -// halt: { -// enumerable: false, -// value() { -// return Object.defineProperties(Object.create(Promise.prototype), { -// [Symbol.iterator]: { -// enumerable: false, -// *value() { -// halt(); -// yield* halted.future; -// }, -// }, -// then: { -// enumerable: false, -// value(...args: Parameters) { -// halt(); -// return halted.future.then(...args); -// }, -// }, -// catch: { -// enumerable: false, -// value(...args: Parameters) { -// halt(); -// return halted.future.catch(...args); -// }, -// }, -// finally: { -// enumerable: false, -// value(...args: Parameters) { -// halt(); -// return halted.future.finally(...args); -// }, -// }, -// }); -// }, -// }, -// [Symbol.iterator]: { -// enumerable: false, -// value: future.future[Symbol.iterator], -// }, -// [Symbol.toStringTag]: { -// enumerable: false, -// value: "Task", -// }, -// }) as Task; - -// link.add(task); - -// let start = () => routine.next(Ok()); - -// return { task, scope, routine, start }; -// } - -// const TaskLinkContext = createContext("@effection/tasks", { -// add() {}, -// finalized() {}, -// }); - -// interface TaskLink { -// add(task: Task): void; -// finalized( -// task: Task, -// outcome: Maybe>, -// finalization: Result, -// ): void; -// } - -// class TaskTree implements TaskLink { -// linked = true; -// tasks = new Set>(); -// constructor(public crash: (error: Error) => void) {} -// add(task: Task) { -// this.tasks.add(task); -// } -// finalized( -// task: Task, -// outcome: Maybe>, -// finalization: Result, -// ) { -// this.tasks.delete(task); -// if (this.linked) { -// if (!finalization.ok) { -// this.crash(finalization.error); -// } else if (outcome.exists && !outcome.value.ok) { -// this.crash(outcome.value.error); -// } -// } -// } - -// *destroy() { -// let result = Ok(); -// while (this.tasks.size > 0) { -// let tasks = [...this.tasks].reverse(); -// for (let task of tasks) { -// this.tasks.delete(task); -// } -// for (let task of tasks) { -// try { -// yield* task.halt(); -// } catch (error) { -// result = Err(error as Error); -// } -// } -// } -// if (!result.ok) { -// throw result.error; -// } -// } -// } - -// export function encapsulate(op: () => Operation): Operation { -// return TaskGroupContext.with(new TaskGroup(), function* (group) { -// try { -// return yield* op(); -// } finally { -// yield* group.halt(); -// } -// }); -// } - -// export function trap(op: () => Operation): Operation { -// return op(); -// } - -// export function createTask(options: TaskOptions): NewTask { -// let { owner, operation } = options; -// let [scope, destroy] = createScopeInternal(owner); - -// TaskGroup.ensureOwn(scope); - -// let routine = createCoroutine({ -// scope, -// operation: () => trapset(() => after(operation, destroy)), -// }); - -// let promise = lazyPromiseWithResolvers(); -// let future = withResolvers(); - -// let resolve = (value: T) => { -// promise.resolve(value); -// future.resolve(value); -// }; - -// let reject = (error: Error) => { -// promise.reject(error); -// future.reject(error); -// }; - -// let initiateHalt = (resolve: Resolve>) => { -// if (scope.hasOwn(TrapContext)) { -// let trap = scope.expect(TrapContext); -// let current = routine.data.discard; -// routine.data.discard = (exit) => -// current((result) => { -// if (!result.ok) { -// trap.result = result; -// } -// exit(result); -// }); -// return routine.return( -// trap.result = Ok(Nothing()), -// drain((result) => resolve(result.ok ? Ok() : result)), -// ); -// } else { -// return routine.return( -// Ok(Nothing()), -// drain((result) => resolve(result.ok ? Ok() : result)), -// ); -// } -// }; - -// let halt = lazyPromise((resolve, reject) => { -// initiateHalt((result) => result.ok ? resolve() : reject(result.error)); -// }); - -// Object.defineProperty(halt, Symbol.iterator, { -// enumerable: false, -// *value(): Operation { -// yield ({ -// description: "halt", -// enter: (resolve) => { -// let unsubscribe = initiateHalt(resolve); - -// return (done) => { -// unsubscribe(); -// done(Ok()); -// }; -// }, -// }); -// }, -// }); - -// let task = Object.defineProperties(promise.promise, { -// [Symbol.toStringTag]: { -// enumerable: false, -// value: "Task", -// }, -// [Symbol.iterator]: { -// enumerable: false, -// value: future.operation[Symbol.iterator], -// }, -// halt: { -// enumerable: false, -// value: () => halt, -// }, -// }) as Task; - -// let group = TaskGroup.ensureOwn(owner); - -// let link = group.link(owner, task); - -// scope.set(Routine, routine); - -// let start = () => -// routine.next( -// Ok(), -// drain((result) => { -// link.close(result); -// if (result.ok) { -// if (result.value.exists) { -// resolve(result.value.value); -// } else { -// reject(new Error("halted")); -// } -// } else { -// reject(result.error); -// } -// }), -// ); - -// return { task, scope, routine, start }; -// } - -// export const TaskGroupContext = createContext("@effection/tasks"); - -// export function encapsulate(operation: () => Operation): Operation { -// return TaskGroupContext.with(new TaskGroup(), function* (group) { -// try { -// return yield* operation(); -// } finally { -// yield* group.halt(); -// } -// }); -// } - -// class TaskGroup { -// static ensureOwn(scope: ScopeInternal): TaskGroup { -// if (!scope.hasOwn(TaskGroupContext)) { -// let group = scope.set(TaskGroupContext, new TaskGroup()); -// scope.ensure(() => group.halt()); -// } -// return scope.expect(TaskGroupContext); -// } - -// links = new Set>(); - -// link(owner: Scope, task: Task) { -// return new TaskLink(owner, task, this.links); -// } - -// *halt() { -// let links = [...this.links].reverse(); -// links.forEach((link) => link.sever()); -// let outcome = Ok(); -// for (let link of links) { -// let result = yield* box(link.task.halt); -// if (!result.ok) { -// outcome = result; -// } -// } -// return unbox(outcome); -// } -// } - -// class TaskLink { -// constructor( -// public owner: Scope, -// public task: Task, -// public links: Set>, -// ) { -// this.links.add(this); -// } - -// close(result: Result>) { -// this.links.delete(this); -// if (!result.ok) { -// let trap = this.owner.get(TrapContext); -// if (trap) { -// trap.result = result; -// this.owner.get(Routine)?.return(trap.result); -// } -// } -// } - -// sever() { -// this.links.delete(this); -// this.close = () => {}; -// } -// } - -// const TrapContext = createContext<{ result: Result> }>( -// "@effection/trap", -// ); - -// function trapset(op: () => Operation): Operation> { -// let result = Ok(Nothing()); -// return TrapContext.with({ result }, function* (trap) { -// try { -// let value = yield* op(); -// if (trap.result === result) { -// trap.result = Ok(Just(value)); -// } -// } catch (error) { -// trap.result = Err(error as Error); -// } finally { -// // deno-lint-ignore no-unsafe-finally -// return unbox(trap.result) as Maybe; -// } -// }); -// } - -// export function* trap(op: () => Operation): Operation { -// let outcome = yield* trapset(op); -// if (outcome.exists) { -// return outcome.value; -// } else { -// return (yield { -// description: "propagate halt", -// enter: (resolve, routine) => { -// let trap = routine.scope.expect(TrapContext); -// trap.result = Ok(Nothing()); -// routine.return(trap.result); -// resolve(Ok()); -// return (resolve) => { -// resolve(Ok()); -// }; -// }, -// }) as T; -// } -// } - -// function* after( -// op: () => Operation, -// epilogue: () => Operation, -// ): Operation { -// try { -// return yield* op(); -// } finally { -// yield* epilogue(); -// } -// } From 33f249995a44e0f779c85be748e6ae267ce6f6ad Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 10 Jul 2025 17:36:09 +0300 Subject: [PATCH 058/119] Shutdown all tasks when a failure occurs in any of them --- lib/all.ts | 28 +++++++++++++++++----------- lib/race.ts | 7 ------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/all.ts b/lib/all.ts index ff3d8172b..1b5d18f07 100644 --- a/lib/all.ts +++ b/lib/all.ts @@ -31,19 +31,25 @@ export function* all[] | []>( ops: T, ): Operation> { let tasks: Task[] = []; - - return yield* trap(function* (): Operation> { - for (let operation of ops) { - const member = () => operation; - tasks.push(yield* spawn(member)); - } - let results: unknown[] = []; + try { + return yield* trap(function* (): Operation> { + for (let operation of ops) { + const member = () => operation; + tasks.push(yield* spawn(member)); + } + let results: unknown[] = []; + for (let task of tasks) { + let result = yield* task; + results.push(result); + } + return results as All; + }); + } catch (error) { for (let task of tasks) { - let result = yield* task; - results.push(result); + yield* task.halt(); } - return results as All; - }); + throw error; + } } /** diff --git a/lib/race.ts b/lib/race.ts index a234f2e1e..ebbf96b3d 100644 --- a/lib/race.ts +++ b/lib/race.ts @@ -4,9 +4,6 @@ import type { Operation, Task, Yielded } from "./types.ts"; import { withResolvers } from "./with-resolvers.ts"; import { Err, Ok, type Result } from "./result.ts"; -//import { useScope } from "./scope.ts"; -//import { transfer } from "./scope.ts"; - /** * Race the given operations against each other and return the value of * whichever operation returns first. This has the same purpose as @@ -34,7 +31,6 @@ import { Err, Ok, type Result } from "./result.ts"; export function* race>( operations: readonly T[], ): Operation> { - // let caller = yield* useScope(); let winner = withResolvers>>("await winner"); let tasks: Task[] = []; @@ -44,11 +40,8 @@ export function* race>( for (let operation of operations.slice()) { tasks.push( yield* spawn(function* candidate() { - // let contestant = yield* useScope(); try { let value = yield* operation; - // Transfer the winner to the contestant - // transfer({ from: contestant, to: caller }); winner.resolve(Ok(value as Yielded)); } catch (error) { winner.resolve(Err(error as Error)); From cda4c874dc0101b426adfc20032e597cd2353142 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 10 Jul 2025 17:56:45 +0300 Subject: [PATCH 059/119] =?UTF-8?q?=F0=9F=A7=AA=20add=20test=20to=20make?= =?UTF-8?q?=20sure=20all()=20tears=20down=20all=20tasks=20when=20it=20fail?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/all.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/test/all.test.ts b/test/all.test.ts index 73fdfab6f..4402ae439 100644 --- a/test/all.test.ts +++ b/test/all.test.ts @@ -10,7 +10,9 @@ import { syncResolve, } from "./suite.ts"; -import { all, call, type Operation, run, sleep } from "../mod.ts"; +import { all, call, type Operation, run, sleep, suspend } from "../mod.ts"; +import { box } from "../lib/box.ts"; +import assert from "node:assert"; describe("all()", () => { it("resolves when the given list is empty", async () => { @@ -104,4 +106,25 @@ describe("all()", () => { all([resolve("hello"), resolve(42)]), ); }); + + it("shuts down all tasks when anything fails", async () => { + let teardown = false; + await run(function* () { + let result = yield* box(() => + all([ + asyncReject(0, "foo"), + call(function* () { + try { + yield* suspend(); + } finally { + teardown = true; + } + }), + ]) + ); + + assert(!result.ok, "all should fail"); + expect(teardown).toEqual(true); + }); + }); }); From 4e6ea21dd011954fc268403c89d9bb3a011805c4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 24 Jul 2025 13:24:21 -0500 Subject: [PATCH 060/119] =?UTF-8?q?=E2=9C=85=20Add=20test=20for=20task=20p?= =?UTF-8?q?riority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/thefrontside/effection/issues/998 --- test/spawn.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/spawn.test.ts b/test/spawn.test.ts index 5b6fbb379..bf2545d1d 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -207,4 +207,23 @@ describe("spawn", () => { }); await expect(task).rejects.toHaveProperty("message", "moo"); }); + + it("executes tasks first in first out within a priority group", async () => { + let order: string[] = []; + await run(function* () { + yield* spawn(function* () { + order.push("one"); + }); + + yield* spawn(function* () { + order.push("two"); + }); + + order.push("zero"); + + yield* sleep(0); + }); + + expect(order).toEqual(["zero", "one", "two"]); + }); }); From 46ebc22eb80dd1f78dfc7e437de922fd9ac9f124 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 11 Jul 2025 15:17:17 +0300 Subject: [PATCH 061/119] =?UTF-8?q?=E2=9C=85=20Add=20a=20test=20ensuring?= =?UTF-8?q?=20that=20a=20task=20can=20block=20its=20parent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/ensure.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/ensure.test.ts b/test/ensure.test.ts index 5579f9b14..0827d95e3 100644 --- a/test/ensure.test.ts +++ b/test/ensure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "./suite.ts"; -import { ensure, run, sleep } from "../mod.ts"; +import { ensure, run, sleep, spawn } from "../mod.ts"; describe("ensure", () => { it("runs the given operation at the end of the task", async () => { @@ -38,4 +38,18 @@ describe("ensure", () => { await root; expect(state).toEqual("completed"); }); + + it("can block a task conmpleting until a subtask completes", async () => { + let reached = false; + + await run(function* () { + let task = yield* spawn(function* () { + yield* sleep(0); + reached = true; + }); + yield* ensure(() => task); + }); + + expect(reached).toEqual(true); + }); }); From cbe6ab982c316e5bc918fc6148158dcdf29babbe Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 24 Jul 2025 13:37:53 -0500 Subject: [PATCH 062/119] =?UTF-8?q?=F0=9F=90=9B=20do=20not=20attempt=20to?= =?UTF-8?q?=20bundle=20dynamic=20`node:process`=20import=20in=20webpack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/main.ts b/lib/main.ts index aaafb69da..2076715ab 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -96,8 +96,10 @@ export async function main( } }, *node() { + // Annotate dynamic import so that webpack ignores it. + // See https://webpack.js.org/api/module-methods/#webpackignore let { default: process } = yield* call(() => - import("node:process") + import(/* webpackIgnore: true */ "node:process") ); hardexit = (status) => process.exit(status); try { From 02b2e944475f3790be1bd0bc61070a2707c5b306 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 31 Jul 2025 14:43:16 -0500 Subject: [PATCH 063/119] =?UTF-8?q?=E2=9C=A8=20properly=20propagate=20erro?= =?UTF-8?q?rs=20from=20nested=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was a problem where raising errors within nested scopes was causing the outermost delimiter to have its outcome overwritten by its children propagating up. This changes delimiters so that their outcomes cannot be overwritten unless that outcome is, in fact, an error. --- lib/delimiter.ts | 9 ++++++--- test/scoped.test.ts | 17 +++++++++-------- test/spawn.test.ts | 1 - 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/delimiter.ts b/lib/delimiter.ts index a9cd804af..c224410d7 100644 --- a/lib/delimiter.ts +++ b/lib/delimiter.ts @@ -50,13 +50,16 @@ export class Delimiter if (this.finalized) { return; } - this.outcome = outcome; + this.outcome = + (this.outcome && this.outcome.exists && !this.outcome.value.ok) + ? this.outcome + : outcome; this.level++; if (!this.routine) { this.finalized = true; - this.future.resolve(outcome); + this.future.resolve(this.outcome); } else { - this.routine.return(Ok(outcome)); + this.routine.return(Ok(this.outcome)); } } diff --git a/test/scoped.test.ts b/test/scoped.test.ts index c79b57c6a..3d8df697b 100644 --- a/test/scoped.test.ts +++ b/test/scoped.test.ts @@ -1,3 +1,4 @@ +import { box } from "../lib/box.ts"; import { createContext, resource, @@ -157,7 +158,7 @@ describe("scoped", () => { })); }); - it.skip("throws errors at the correct point when there are multiple nested scopes", async () => { + it("throws errors at the correct point when there are multiple nested scopes", async () => { let task = run(function* () { return yield* scoped(function* () { yield* spawn(function* () { @@ -165,13 +166,13 @@ describe("scoped", () => { throw new Error("boom!"); }); - try { - return yield* scoped(function* () { - yield* suspend(); - }); - } catch (error) { - return error; - } + yield* box(() => + scoped(function* () { + yield* scoped(function* () { + yield* suspend(); + }); + }) + ); }); }); diff --git a/test/spawn.test.ts b/test/spawn.test.ts index bf2545d1d..7469dd45c 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -122,7 +122,6 @@ describe("spawn", () => { await expect(root.halt()).rejects.toHaveProperty("message", "moo"); await expect(child!.halt()).rejects.toHaveProperty("message", "moo"); - await expect(root.halt()).rejects.toHaveProperty("message", "moo"); }); it("halts when child finishes during asynchronous halt", async () => { From 0301a62603adecfe37b09e8b71d9fae714ea2aec Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Mon, 4 Aug 2025 17:35:18 -0500 Subject: [PATCH 064/119] =?UTF-8?q?=E2=9C=A8=20add=20interval()=20helper?= =?UTF-8?q?=20to=20consume=20a=20stream=20of=20intervals,=20port=20from=20?= =?UTF-8?q?v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/interval.ts | 31 +++++++++++++++++++++++++++++++ lib/mod.ts | 1 + test/interval.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 lib/interval.ts create mode 100644 test/interval.test.ts diff --git a/lib/interval.ts b/lib/interval.ts new file mode 100644 index 000000000..28fb5227e --- /dev/null +++ b/lib/interval.ts @@ -0,0 +1,31 @@ +import { resource } from "./resource.ts"; +import { createSignal } from "./signal.ts"; +import type { Stream } from "./types.ts"; + +/** + * Consume an interval as an infinite stream. + * + * ```ts + * let startTime = Date.now(); + * + * for (let _ of yield* each(interval(10))) { + * let elapsed = Date.now() - startTime; + * console.log(`elapsed time: ${elapsed} ms`); + * yield* each.next(); + * } + * ``` + * @param milliseconds - how long to delay between each item in the stream + */ +export function interval(milliseconds: number): Stream { + return resource(function* (provide) { + let signal = createSignal(); + + let id = setInterval(signal.send, milliseconds); + + try { + yield* provide(yield* signal); + } finally { + clearInterval(id); + } + }); +} diff --git a/lib/mod.ts b/lib/mod.ts index 4b0f0cf63..e15e4d3c5 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -5,6 +5,7 @@ export * from "./context.ts"; export * from "./scope.ts"; export * from "./suspend.ts"; export * from "./sleep.ts"; +export * from "./interval.ts"; export * from "./run.ts"; export * from "./spawn.ts"; export * from "./resource.ts"; diff --git a/test/interval.test.ts b/test/interval.test.ts new file mode 100644 index 000000000..dda79231f --- /dev/null +++ b/test/interval.test.ts @@ -0,0 +1,27 @@ +import { call, each, interval, race, run, sleep, spawn } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +describe("interval", () => { + it("can be iterated", async () => { + await run(function* () { + let task = yield* spawn(function* () { + let total = 0; + for (let _ of yield* each(interval(1))) { + total++; + if (total == 10) { + return total; + } + yield* each.next(); + } + }); + let result = yield* race([ + task, + call(function* () { + yield* sleep(50); + return "interval not producing!"; + }), + ]); + expect(result).toEqual(10); + }); + }); +}); From e46ac5c6ad1ec4a2573623be0f7572e05dbd2d76 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 5 Aug 2025 16:07:20 -0500 Subject: [PATCH 065/119] =?UTF-8?q?=F0=9F=90=9B=20run=20resources=20at=20t?= =?UTF-8?q?he=20priority=20level=20of=20their=20caller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When initializing a resource that does not do anything asynchronous, we do not want to release control of the reduction loop so that the parent can continue to run. - We renamed `Generation` -> `Priority` for the context since the generation and the priority are no longer necessarily the same. - Set the priority of a resource task to the same level as its parent --- lib/contexts.ts | 2 +- lib/coroutine.ts | 6 +++--- lib/resource.ts | 16 +++++++++++++--- lib/scope-internal.ts | 4 ++-- test/resource.test.ts | 23 +++++++++++++++++++++++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/lib/contexts.ts b/lib/contexts.ts index 7e3b80e47..c03cd1859 100644 --- a/lib/contexts.ts +++ b/lib/contexts.ts @@ -5,7 +5,7 @@ export const Routine = createContext>( "@effection/coroutine", ); -export const Generation = createContext( +export const Priority = createContext( "@effection/scope.generation", 0, ); diff --git a/lib/coroutine.ts b/lib/coroutine.ts index f00d16fe5..f0c6a1bfa 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -1,4 +1,4 @@ -import { Generation } from "./contexts.ts"; +import { Priority } from "./contexts.ts"; import { DelimiterContext } from "./delimiter.ts"; import { ReducerContext } from "./reducer.ts"; import { Ok } from "./result.ts"; @@ -32,7 +32,7 @@ export function createCoroutine( routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ - scope.expect(Generation), + scope.expect(Priority), routine, exitResult.ok ? result : exitResult, scope.expect(DelimiterContext).validator, @@ -44,7 +44,7 @@ export function createCoroutine( routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ - scope.expect(Generation), + scope.expect(Priority), routine, exitResult.ok ? result : exitResult, scope.expect(DelimiterContext).validator, diff --git a/lib/resource.ts b/lib/resource.ts index c82adabcc..d653319ce 100644 --- a/lib/resource.ts +++ b/lib/resource.ts @@ -1,9 +1,10 @@ import { suspend } from "./suspend.ts"; -import { spawn } from "./spawn.ts"; import type { Operation } from "./types.ts"; -import { trap } from "./task.ts"; +import { createTask, trap } from "./task.ts"; import { Ok } from "./result.ts"; import { useCoroutine } from "./coroutine.ts"; +import type { ScopeInternal } from "./scope-internal.ts"; +import { Priority } from "./contexts.ts"; /** * Define an Effection [resource](https://frontside.com/effection/docs/resources) @@ -57,7 +58,16 @@ export function resource( // establishing a control boundary lets us catch errors in // resource initializer return yield* trap(function* () { - yield* spawn(() => op(provide)); + let { scope, start } = createTask({ + owner: caller.scope as ScopeInternal, + operation: () => op(provide), + }); + + // a resource runs at the priority of its parent + scope.set(Priority, caller.scope.expect(Priority)); + + start(); + return (yield { description: "await resource", enter: () => (uninstalled) => uninstalled(Ok()), diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index 96c142d41..47b9dc5b0 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -1,4 +1,4 @@ -import { Children, Generation } from "./contexts.ts"; +import { Children, Priority } from "./contexts.ts"; import { Err, Ok, unbox } from "./result.ts"; import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; @@ -57,7 +57,7 @@ export function createScopeInternal( }, }); - scope.set(Generation, scope.expect(Generation) + 1); + scope.set(Priority, scope.expect(Priority) + 1); scope.set(Children, new Set()); parent?.expect(Children).add(scope); diff --git a/test/resource.test.ts b/test/resource.test.ts index 631b77b53..8c66817f3 100644 --- a/test/resource.test.ts +++ b/test/resource.test.ts @@ -84,6 +84,29 @@ describe("resource", () => { expect(state.status).toEqual("pending"); }); + + it("does not relinquish control when a resource initialized synchronously", async () => { + let sequence: number[] = []; + + await run(function* () { + sequence.push(1); + + let task = yield* spawn(function* () { + sequence.push(4); + }); + + yield* resource(function* (provide) { + sequence.push(2); + yield* provide(); + }); + + sequence.push(3); + + yield* task; + }); + + expect(sequence).toEqual([1, 2, 3, 4]); + }); }); function createResource(container: State): Operation { From b3b629a97caff07c94f5e590dd6a0ad6d1b04947 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 10 Aug 2025 13:33:37 -0400 Subject: [PATCH 066/119] Renamed actions page --- docs/actions.mdx | 24 ++++++++++++------------ docs/installation.mdx | 8 +------- docs/structure.json | 6 +++--- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/docs/actions.mdx b/docs/actions.mdx index 14e6dc7d6..76a181794 100644 --- a/docs/actions.mdx +++ b/docs/actions.mdx @@ -1,12 +1,10 @@ -In this section, we'll cover one of the most fundamental operations in -Effection: `action()`, and how we can use it as a safe alternative to +In this section, we'll cover how we can use `action` as a safe alternative to the [Promise constructor][promise-constructor]. ## Example: Sleep Let's revisit our sleep operation from the [introduction to -operations](./operations): - +operations](../operations): ```js export function sleep(duration: number): Operation { @@ -58,7 +56,8 @@ an abort signal was there without sacrificing any clarity in achieving it. ## Action Constructor -The [`action()`][action] function provides a callback based API to create Effection operations. You don't need it all that often, but when you do it functions almost exactly like the +The [`action()`][action] function provides a callback based API to create Effection operations. +You don't need it all that often, but when you do it functions almost exactly like the [promise constructor][promise-constructor]. To see this, let's use [one of the examples from MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise#examples) that uses promises to make a crude replica of the global [`fetch()`][fetch] @@ -90,7 +89,7 @@ function* fetch(url) { xhr.onload = () => resolve(xhr.responseText); xhr.onerror = () => reject(xhr.statusText); xhr.send(); - return () => { } // "finally" function place holder. + return () => {}; // "finally" function place holder. }); } ``` @@ -109,11 +108,11 @@ until _both_ requests are have received a response. ```js await Promise.race([ fetch("https://openweathermap.org"), - fetch("https://open-meteo.org") -]) + fetch("https://open-meteo.org"), +]); ``` -With Effection, this is easily fixed by calling `abort()` in the finally function to +With Effection, this is easily fixed by calling `abort()` in the finally function to make sure that the request is cancelled when it is either resolved, rejected, or passes out of scope. @@ -125,15 +124,16 @@ function* fetch(url) { xhr.onload = () => resolve(xhr.responseText); xhr.onerror = () => reject(xhr.statusText); xhr.send(); - return () => { xhr.abort(); }; // called in all cases + return () => { + xhr.abort(); + }; // called in all cases }); } ``` ->πŸ’‘Almost every usage of the [promise concurrency primitives][promise-concurrency] +> πŸ’‘Almost every usage of the [promise concurrency primitives][promise-concurrency] > will contain bugs born of leaked effects. - [abort-signal]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal [fetch]: https://developer.mozilla.org/en-US/docs/Web/API/fetch [promise-constructor]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise diff --git a/docs/installation.mdx b/docs/installation.mdx index 7eb794564..bdea3161d 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -3,12 +3,6 @@ If you encounter obstacles integrating with your environment, please create a [G ## Node.js & Browser -

- Bundlephobia badge showing effection minified - Bundlephobia badge showing effection gzipped size - Bundlephobia badge showing it's treeshackable -

- Effection is available on [NPM][npm], as well as derived registries such as [Yarn][yarn] and [UNPKG][unpkg]. It comes with TypeScript types and can be consumed as both ESM and CommonJS. ```bash @@ -24,7 +18,7 @@ yarn add effection Effection has first class support for Deno because it is developed with [Deno](https://deno.land). Releases are published to [https://deno.land/x/effection](https://deno.land/x/effection). For example, to import the `main()` function: ```ts -import { main } from "https://deno.land/x/effection/mod.ts"; +import { main } from "jsr:@effection/effection@4.0.0-beta.2"; ``` > πŸ’‘ If you're curious how we keep NPM/YARN and Deno packages in-sync, you can [checkout the blog post on how publish Deno packages to NPM.][deno-npm-publish]. diff --git a/docs/structure.json b/docs/structure.json index 9e748998d..cad56d0ae 100644 --- a/docs/structure.json +++ b/docs/structure.json @@ -8,13 +8,13 @@ ], "Learn Effection": [ ["operations.mdx", "Operations"], - ["actions.mdx", "Actions and Suspensions"], - ["resources.mdx", "Resources"], ["spawn.mdx", "Spawn"], + ["resources.mdx", "Resources"], ["collections.mdx", "Streams and Subscriptions"], ["events.mdx", "Events"], ["errors.mdx", "Error Handling"], - ["context.mdx", "Context"] + ["context.mdx", "Context"], + ["actions.mdx", "Actions"] ], "Advanced": [ ["faq.mdx", "FAQ"], From b77fa708fc2ddb2a0652e96e2e1e91b78c33bbbd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 3 Oct 2025 23:56:02 -0700 Subject: [PATCH 067/119] Do not bind SIGTERM in windows Additionaly: * use ctrlc-windows to terminate processes in Windows in tests --- lib/main.ts | 19 +++++++++++++++---- test/main.test.ts | 18 ++++++++++-------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/lib/main.ts b/lib/main.ts index 2076715ab..4d77d11d5 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -88,11 +88,18 @@ export async function main( hardexit = (status) => Deno.exit(status); try { Deno.addSignalListener("SIGINT", interrupt.SIGINT); - Deno.addSignalListener("SIGTERM", interrupt.SIGTERM); + /** + * Windows only supports ctrl-c (SIGINT), ctrl-break (SIGBREAK), and ctrl-close (SIGUP) + */ + if (Deno.build.os !== "windows") { + Deno.addSignalListener("SIGTERM", interrupt.SIGTERM); + } yield* body(Deno.args.slice()); } finally { Deno.removeSignalListener("SIGINT", interrupt.SIGINT); - Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM); + if (Deno.build.os !== "windows") { + Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM); + } } }, *node() { @@ -104,11 +111,15 @@ export async function main( hardexit = (status) => process.exit(status); try { process.on("SIGINT", interrupt.SIGINT); - process.on("SIGTERM", interrupt.SIGTERM); + if (process.platform !== "win32") { + process.on("SIGTERM", interrupt.SIGTERM); + } yield* body(process.argv.slice(2)); } finally { process.off("SIGINT", interrupt.SIGINT); - process.off("SIGTERM", interrupt.SIGINT); + if (process.platform !== "win32") { + process.off("SIGTERM", interrupt.SIGINT); + } } }, *browser() { diff --git a/test/main.test.ts b/test/main.test.ts index 62c296c4f..8d82ecc33 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -25,19 +25,21 @@ describe("main", () => { }); }); - it("gracefully shuts down on SIGTERM", async () => { - await run(function* () { - let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); + if (Deno.build.os !== "windows") { + it("gracefully shuts down on SIGTERM", async () => { + await run(function* () { + let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); - yield* until(proc.lines, "started"); + yield* until(proc.lines, "started"); - const { exitCode, stdout } = yield* proc.kill("SIGTERM"); + const { exitCode, stdout } = yield* proc.kill("SIGTERM"); - expect(stdout).toContain("gracefully stopped"); + expect(stdout).toContain("gracefully stopped"); - expect(exitCode).toBe(143); + expect(exitCode).toBe(143); + }); }); - }); + } it("exits gracefully on explicit exit()", async () => { await run(function* () { From 032ea4aacfb690363a6f45719a98db9348a70499 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 3 Oct 2025 23:56:31 -0700 Subject: [PATCH 068/119] Run tests on windows as well --- .github/workflows/verify.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 6c6b440d4..5c3b3b638 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -10,8 +10,11 @@ permissions: contents: read jobs: - test-deno: - runs-on: ubuntu-latest + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - name: checkout From f06e9dcfda6dafdf01b82c4ce24713d31beb1488 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 4 Oct 2025 00:16:38 -0700 Subject: [PATCH 069/119] Added windows-latest --- .github/workflows/verify.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 5c3b3b638..0d6faadee 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -13,7 +13,7 @@ jobs: test: strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: From 681b8ccd76fba5adc2409e232cc0a00dc0816ff7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 4 Oct 2025 04:45:02 -0700 Subject: [PATCH 070/119] Added .gitattributes: Created a .gitattributes file to ensure consistent line endings (LF) across all platforms, which should fix the "Text differed by line endings" error in CI. --- .gitattributes | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..04a84238f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Set default line ending behavior +* text=auto + +# Explicitly set line endings for text files +*.ts text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary \ No newline at end of file From ffc913af66ad6055ef8cab9f249450e6f45df85e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 4 Oct 2025 04:47:40 -0700 Subject: [PATCH 071/119] Added mjs files to gitattributes --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 04a84238f..d0f5542ff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ # Explicitly set line endings for text files *.ts text eol=lf +*.mjs text eol=lf *.js text eol=lf *.json text eol=lf *.md text eol=lf From 1d8ce29bc021f77911d02be69810bb158a0a4f32 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 6 Oct 2025 17:06:53 -0700 Subject: [PATCH 072/119] Do not run lint on docs and tasks --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index f0f7a5801..2afc16677 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,7 @@ }, "lint": { "rules": { "exclude": ["prefer-const", "require-yield"] }, - "exclude": ["build", "www"] + "exclude": ["build", "www", "docs", "tasks"] }, "fmt": { "exclude": ["build", "www", "CODE_OF_CONDUCT.md", "README.md"] }, "test": { "exclude": ["build"] }, From 3074a066941b85661f82e0731520662c2de9400d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 6 Oct 2025 17:19:45 -0700 Subject: [PATCH 073/119] Restored v3 main tests --- deno.json | 4 +- test/main.test.ts | 115 +++++++++++++++++++++++++----------------- test/suite.ts | 126 +++++++++++++++++++++++++++++++--------------- 3 files changed, 157 insertions(+), 88 deletions(-) diff --git a/deno.json b/deno.json index 2afc16677..d9b06ecc3 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,7 @@ "publish": { "include": ["lib", "mod.ts", "README.md"] }, "lock": false, "tasks": { - "test": "deno test --allow-run=deno --allow-read --allow-env", + "test": "deno test --allow-run=deno --allow-read --allow-env --allow-ffi", "test:node": "deno run -A tasks/built-test.ts", "build:npm": "deno run -A tasks/build-npm.ts", "bench": "deno run -A tasks/bench.ts", @@ -22,7 +22,7 @@ "@std/testing": "jsr:@std/testing@1", "ts-expect": "npm:ts-expect@1.3.0", "@std/expect": "jsr:@std/expect@1", - "tinyexec": "npm:tinyexec@0.3.2" + "ctrlc-windows": "npm:ctrlc-windows@2.2.0" }, "version": "4.0.0-alpha.8" } diff --git a/test/main.test.ts b/test/main.test.ts index 8d82ecc33..00713fd2f 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,101 +1,126 @@ -import { describe, expect, it, x } from "./suite.ts"; -import { each, run, type Stream } from "../mod.ts"; - -function* until(stream: Stream, text: string) { - for (const line of yield* each(stream)) { - if (line.includes(text)) { - return; - } - yield* each.next(); - } -} +import { buffer, describe, detect, expect, it, useCommand } from "./suite.ts"; +import { run, until } from "../mod.ts"; describe("main", () => { it("gracefully shuts down on SIGINT", async () => { await run(function* () { - let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); + let daemon = yield* useCommand("deno", { + stdout: "piped", + args: ["run", "test/main/ok.daemon.ts"], + }); + let stdout = yield* buffer(daemon.stdout); + yield* detect(stdout, "started"); - yield* until(proc.lines, "started"); + daemon.kill("SIGINT"); - const { exitCode, stdout } = yield* proc.kill("SIGINT"); + let status = yield* until(daemon.status); - expect(stdout).toContain("gracefully stopped"); + expect(status.code).toBe(130); - expect(exitCode).toBe(130); + yield* detect(stdout, "gracefully stopped"); }); }); if (Deno.build.os !== "windows") { it("gracefully shuts down on SIGTERM", async () => { await run(function* () { - let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); + let daemon = yield* useCommand("deno", { + stdout: "piped", + args: ["run", "test/main/ok.daemon.ts"], + }); + let stdout = yield* buffer(daemon.stdout); + yield* detect(stdout, "started"); - yield* until(proc.lines, "started"); + daemon.kill("SIGTERM"); - const { exitCode, stdout } = yield* proc.kill("SIGTERM"); + let status = yield* until(daemon.status); - expect(stdout).toContain("gracefully stopped"); + expect(status.code).toBe(143); - expect(exitCode).toBe(143); + yield* detect(stdout, "gracefully stopped"); }); }); } it("exits gracefully on explicit exit()", async () => { await run(function* () { - let proc = yield* x("deno", ["run", "test/main/ok.exit.ts"]); + let cmd = yield* useCommand("deno", { + stdout: "piped", + args: ["run", "test/main/ok.exit.ts"], + }); - yield* until(proc.lines, "goodbye."); - yield* until(proc.lines, "Ok, computer."); + let stdout = yield* buffer(cmd.stdout); + + yield* detect(stdout, "goodbye.\nOk, computer."); }); }); it("exits gracefully with 0 on implicit exit", async () => { await run(function* () { - let proc = yield* x("deno", ["run", "test/main/ok.implicit.ts"]); - - yield* until(proc.lines, "goodbye."); + let cmd = yield* useCommand("deno", { + stdout: "piped", + args: ["run", "test/main/ok.implicit.ts"], + }); - const { exitCode } = yield* proc; + let stdout = yield* buffer(cmd.stdout); + let status = yield* until(cmd.status); - expect(exitCode).toEqual(0); + yield* detect(stdout, "goodbye."); + expect(status.code).toEqual(0); }); }); it("exits gracefully on explicit exit failure exit()", async () => { await run(function* () { - let proc = yield* x("deno", ["run", "test/main/fail.exit.ts"]); - - const { stderr, exitCode, stdout } = yield* proc; + let cmd = yield* useCommand("deno", { + stdout: "piped", + stderr: "piped", + args: ["run", "test/main/fail.exit.ts"], + }); + let stdout = yield* buffer(cmd.stdout); + let stderr = yield* buffer(cmd.stderr); + let status = yield* until(cmd.status); - expect(stdout).toContain("graceful goodbye"); - expect(stderr).toContain("It all went horribly wrong"); - expect(exitCode).toEqual(23); + yield* detect(stdout, "graceful goodbye"); + yield* detect(stderr, "It all went horribly wrong"); + expect(status.code).toEqual(23); }); }); it("error exits gracefully on unexpected errors", async () => { await run(function* () { - let proc = yield* x("deno", ["run", "test/main/fail.unexpected.ts"]); + let cmd = yield* useCommand("deno", { + stdout: "piped", + stderr: "piped", + args: ["run", "test/main/fail.unexpected.ts"], + }); - const { stderr, stdout, exitCode } = yield* proc; + let stdout = yield* buffer(cmd.stdout); + let stderr = yield* buffer(cmd.stderr); + let status = yield* until(cmd.status); - expect(stdout).toContain("graceful goodbye"); - expect(stderr).toContain("Error: moo"); - expect(exitCode).toEqual(1); + yield* detect(stdout, "graceful goodbye"); + yield* detect(stderr, "Error: moo"); + expect(status.code).toEqual(1); }); }); it("works even if suspend is the only operation", async () => { await run(function* () { - let proc = yield* x("deno", ["run", "test/main/just.suspend.ts"]); + let process = yield* useCommand("deno", { + stdout: "piped", + args: ["run", "test/main/just.suspend.ts"], + }); + let stdout = yield* buffer(process.stdout); + yield* detect(stdout, "started"); + + process.kill("SIGINT"); - yield* until(proc.lines, "started"); + let status = yield* until(process.status); - const { exitCode, stdout } = yield* proc.kill("SIGINT"); + expect(status.code).toBe(130); - expect(exitCode).toBe(130); - expect(stdout).toContain("gracefully stopped"); + yield* detect(stdout, "gracefully stopped"); }); }); }); diff --git a/test/suite.ts b/test/suite.ts index 94d698fc8..e267fa066 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -1,10 +1,12 @@ -export { expect } from "@std/expect"; +import { expect } from "@std/expect"; export { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; export { expectType } from "ts-expect"; -import { type KillSignal, type Options, type Output, x as $x } from "tinyexec"; +export { expect }; -import type { Operation, Stream } from "../lib/types.ts"; -import { call, resource, sleep, spawn, stream } from "../mod.ts"; +import { ctrlc } from "ctrlc-windows"; + +import type { Operation } from "../lib/types.ts"; +import { resource, sleep, spawn, until } from "../mod.ts"; export function* createNumber(value: number): Operation { yield* sleep(1); @@ -55,49 +57,91 @@ export function* syncReject(value: string): Operation { throw new Error(`boom: ${value}`); } -export interface TinyProcess extends Operation { - /** - * A stream of lines coming from both stdin and stdout. The stream - * will terminate when stdout and stderr are closed which usually - * corresponds to the process ending. - */ - lines: Stream; - - /** - * Send `signal` to this process - * @param signal - the OS signal to send to the process - * @returns void - */ - kill(signal?: KillSignal): Operation; -} - -export function x( +export function useCommand( cmd: string, - args: string[] = [], - options?: Partial, -): Operation { + options?: Deno.CommandOptions, +): Operation { return resource(function* (provide) { - let tinyexec = $x(cmd, args, { ...options }); + let command = new Deno.Command(cmd, options); + let process = command.spawn(); - let promise: Promise = tinyexec as unknown as Promise; - - let output = call(() => promise); - - let tinyproc: TinyProcess = { - *[Symbol.iterator]() { - return yield* output; - }, - lines: stream(tinyexec), - *kill(signal) { - tinyexec.kill(signal); - return yield* output; - }, - }; + if (Deno.build.os === "windows") { + // Wrap the kill method to use ctrlc-windows on Windows + // See: https://github.com/denoland/deno/issues/29599 + const originalKill = process.kill.bind(process); + process.kill = (signal) => { + if (signal === "SIGINT") { + ctrlc(process.pid); + } else { + originalKill(signal); + } + }; + } try { - yield* provide(tinyproc); + yield* provide(process); } finally { - yield* tinyproc.kill(); + try { + process.kill("SIGINT"); + yield* until(process.status); + } catch (error) { + // if the process already quit, then this error is expected. + // unfortunately there is no way (I know of) to check this + // before calling process.kill() + + if ( + !!error && + !(error as Error).message.includes( + "Child process has already terminated", + ) + ) { + // deno-lint-ignore no-unsafe-finally + throw error; + } + } } }); } + +interface Buffer { + content: string; +} + +export function buffer(stream: ReadableStream): Operation { + return resource<{ content: string }>(function* (provide) { + let buff = { content: " " }; + yield* spawn(function* () { + let decoder = new TextDecoder(); + let reader = stream.getReader(); + + try { + let next = yield* until(reader.read()); + while (!next.done) { + buff.content += decoder.decode(next.value); + next = yield* until(reader.read()); + } + } finally { + yield* until(reader.cancel()); + } + }); + + yield* provide(buff); + }); +} + +export function* detect( + buffer: Buffer, + text: string, + options: { timeout: number } = { timeout: 1000 }, +): Operation { + let start = new Date().getTime(); + + while ((new Date().getTime() - start) < options.timeout) { + if (buffer.content.includes(text)) { + return; + } + yield* sleep(10); + } + + expect(buffer.content).toMatch(new RegExp(text)); +} From 13ce48567c1423a2c518976169256ee319fd8f30 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 6 Oct 2025 17:25:13 -0700 Subject: [PATCH 074/119] use cmd in windows --- .github/workflows/verify.yaml | 8 +++++++- deno.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 0d6faadee..2dc1214ef 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -31,7 +31,13 @@ jobs: - name: lint run: deno lint - - name: test + - name: test (Windows) + if: runner.os == 'Windows' + run: deno task test + shell: cmd + + - name: test (Unix) + if: runner.os != 'Windows' run: deno task test test-node: diff --git a/deno.json b/deno.json index d9b06ecc3..1f65b44fa 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,7 @@ "publish": { "include": ["lib", "mod.ts", "README.md"] }, "lock": false, "tasks": { - "test": "deno test --allow-run=deno --allow-read --allow-env --allow-ffi", + "test": "deno test --allow-run --allow-read --allow-env --allow-ffi", "test:node": "deno run -A tasks/built-test.ts", "build:npm": "deno run -A tasks/build-npm.ts", "bench": "deno run -A tasks/bench.ts", From a773bab326cab464644621d29659aee69fe71e6b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 21 Oct 2025 05:11:19 -0400 Subject: [PATCH 075/119] Try pure node process executor --- tasks/built-test.ts | 25 ++++++-- test/main.test.ts | 28 ++++----- test/suite.ts | 135 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 147 insertions(+), 41 deletions(-) diff --git a/tasks/built-test.ts b/tasks/built-test.ts index 09111202e..c61a07f08 100644 --- a/tasks/built-test.ts +++ b/tasks/built-test.ts @@ -1,4 +1,5 @@ import { build, emptyDir } from "jsr:@deno/dnt@0.41.3"; +import { copy } from "jsr:@std/fs@^1"; const outDir = "./build/test"; @@ -7,11 +8,6 @@ await emptyDir(outDir); const entryPoints = [ "./lib/mod.ts", ]; -for await (const entry of Deno.readDir("test")) { - if (entry.isFile) { - entryPoints.push(`./test/${entry.name}`); - } -} await build({ entryPoints, @@ -19,7 +15,10 @@ await build({ shims: { deno: true, }, + test: true, typeCheck: false, + scriptModule: false, + esModule: true, compilerOptions: { lib: ["ESNext", "DOM"], target: "ES2020", @@ -32,4 +31,20 @@ await build({ version: "0.0.0", sideEffects: false, }, + postBuild: async () => { + await Deno.mkdir("./build/test/esm/test/main", { recursive: true }); + for await (const file of Deno.readDir("./test/main")) { + if (file.isFile) { + const content = await Deno.readTextFile(`./test/main/${file.name}`); + const newContent = content.replaceAll( + `from "../../mod.ts"`, + `from "../../mod.js"`, + ); + await Deno.writeTextFile( + `./build/test/esm/test/main/${file.name}`, + newContent, + ); + } + } + }, }); diff --git a/test/main.test.ts b/test/main.test.ts index 00713fd2f..bb9460bc3 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,19 +1,19 @@ import { buffer, describe, detect, expect, it, useCommand } from "./suite.ts"; -import { run, until } from "../mod.ts"; +import { run } from "../mod.ts"; describe("main", () => { it("gracefully shuts down on SIGINT", async () => { await run(function* () { let daemon = yield* useCommand("deno", { stdout: "piped", - args: ["run", "test/main/ok.daemon.ts"], + args: ["run", "--allow-env", "test/main/ok.daemon.ts"], }); let stdout = yield* buffer(daemon.stdout); yield* detect(stdout, "started"); daemon.kill("SIGINT"); - let status = yield* until(daemon.status); + let status = yield* daemon.status; expect(status.code).toBe(130); @@ -26,14 +26,14 @@ describe("main", () => { await run(function* () { let daemon = yield* useCommand("deno", { stdout: "piped", - args: ["run", "test/main/ok.daemon.ts"], + args: ["run", "--allow-env", "test/main/ok.daemon.ts"], }); let stdout = yield* buffer(daemon.stdout); yield* detect(stdout, "started"); daemon.kill("SIGTERM"); - let status = yield* until(daemon.status); + let status = yield* daemon.status; expect(status.code).toBe(143); @@ -46,7 +46,7 @@ describe("main", () => { await run(function* () { let cmd = yield* useCommand("deno", { stdout: "piped", - args: ["run", "test/main/ok.exit.ts"], + args: ["run", "--allow-env", "test/main/ok.exit.ts"], }); let stdout = yield* buffer(cmd.stdout); @@ -59,11 +59,11 @@ describe("main", () => { await run(function* () { let cmd = yield* useCommand("deno", { stdout: "piped", - args: ["run", "test/main/ok.implicit.ts"], + args: ["run", "--allow-env", "test/main/ok.implicit.ts"], }); let stdout = yield* buffer(cmd.stdout); - let status = yield* until(cmd.status); + let status = yield* cmd.status; yield* detect(stdout, "goodbye."); expect(status.code).toEqual(0); @@ -75,11 +75,11 @@ describe("main", () => { let cmd = yield* useCommand("deno", { stdout: "piped", stderr: "piped", - args: ["run", "test/main/fail.exit.ts"], + args: ["run", "--allow-env", "test/main/fail.exit.ts"], }); let stdout = yield* buffer(cmd.stdout); let stderr = yield* buffer(cmd.stderr); - let status = yield* until(cmd.status); + let status = yield* cmd.status; yield* detect(stdout, "graceful goodbye"); yield* detect(stderr, "It all went horribly wrong"); @@ -92,12 +92,12 @@ describe("main", () => { let cmd = yield* useCommand("deno", { stdout: "piped", stderr: "piped", - args: ["run", "test/main/fail.unexpected.ts"], + args: ["run", "--allow-env", "test/main/fail.unexpected.ts"], }); let stdout = yield* buffer(cmd.stdout); let stderr = yield* buffer(cmd.stderr); - let status = yield* until(cmd.status); + let status = yield* cmd.status; yield* detect(stdout, "graceful goodbye"); yield* detect(stderr, "Error: moo"); @@ -109,14 +109,14 @@ describe("main", () => { await run(function* () { let process = yield* useCommand("deno", { stdout: "piped", - args: ["run", "test/main/just.suspend.ts"], + args: ["run", "--allow-env", "test/main/just.suspend.ts"], }); let stdout = yield* buffer(process.stdout); yield* detect(stdout, "started"); process.kill("SIGINT"); - let status = yield* until(process.status); + let status = yield* process.status; expect(status.code).toBe(130); diff --git a/test/suite.ts b/test/suite.ts index e267fa066..1bc64c6a0 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -4,9 +4,16 @@ export { expectType } from "ts-expect"; export { expect }; import { ctrlc } from "ctrlc-windows"; +import { spawn as nodeSpawn } from "node:child_process"; +import type { ChildProcess as NodeChildProcess } from "node:child_process"; +import type { Readable as NodeReadable } from "node:stream"; import type { Operation } from "../lib/types.ts"; -import { resource, sleep, spawn, until } from "../mod.ts"; +import { resource, sleep, spawn, until, withResolvers } from "../mod.ts"; + +interface ChildProcess extends NodeChildProcess { + status: Operation<{ code: number | null; signal: NodeJS.Signals | null }>; +} export function* createNumber(value: number): Operation { yield* sleep(1); @@ -59,31 +66,67 @@ export function* syncReject(value: string): Operation { export function useCommand( cmd: string, - options?: Deno.CommandOptions, -): Operation { + options?: { + args?: string[]; + cwd?: string; + env?: Record; + stdin?: "piped" | "inherit" | "null"; + stdout?: "piped" | "inherit" | "null"; + stderr?: "piped" | "inherit" | "null"; + }, +): Operation { return resource(function* (provide) { - let command = new Deno.Command(cmd, options); - let process = command.spawn(); + const nodeProcess = nodeSpawn(cmd, options?.args ?? [], { + cwd: options?.cwd, + env: options?.env, + stdio: [ + options?.stdin === "piped" + ? "pipe" + : options?.stdin === "null" + ? "ignore" + : "inherit", + options?.stdout === "piped" + ? "pipe" + : options?.stdout === "null" + ? "ignore" + : "inherit", + options?.stderr === "piped" + ? "pipe" + : options?.stderr === "null" + ? "ignore" + : "inherit", + ], + }); + + const status = withResolvers(); + + nodeProcess.on("exit", (code, signal) => status.resolve({ code, signal })); + nodeProcess.on("error", status.reject); - if (Deno.build.os === "windows") { + const processWrapper = Object.assign(nodeProcess, { + status: status.operation, + }) as ChildProcess; + + if (processWrapper.pid && Deno.build.os === "windows") { // Wrap the kill method to use ctrlc-windows on Windows // See: https://github.com/denoland/deno/issues/29599 - const originalKill = process.kill.bind(process); - process.kill = (signal) => { + const originalKill = processWrapper.kill.bind(processWrapper); + processWrapper.kill = (signal?: NodeJS.Signals | number) => { if (signal === "SIGINT") { - ctrlc(process.pid); + ctrlc(processWrapper.pid!); + return true; } else { - originalKill(signal); + return originalKill(signal); } }; } try { - yield* provide(process); + yield* provide(processWrapper); } finally { try { - process.kill("SIGINT"); - yield* until(process.status); + processWrapper.kill("SIGINT"); + yield* status.operation; } catch (error) { // if the process already quit, then this error is expected. // unfortunately there is no way (I know of) to check this @@ -107,21 +150,69 @@ interface Buffer { content: string; } -export function buffer(stream: ReadableStream): Operation { +export function buffer( + stream: NodeReadable | ReadableStream | null, +): Operation { return resource<{ content: string }>(function* (provide) { let buff = { content: " " }; yield* spawn(function* () { let decoder = new TextDecoder(); - let reader = stream.getReader(); - try { - let next = yield* until(reader.read()); - while (!next.done) { - buff.content += decoder.decode(next.value); - next = yield* until(reader.read()); + if (!stream) { + return; + } + + if ("getReader" in stream) { + // ReadableStream (Web API) + let reader = stream.getReader(); + try { + let next = yield* until(reader.read()); + while (!next.done) { + buff.content += decoder.decode(next.value); + next = yield* until(reader.read()); + } + } finally { + yield* until(reader.cancel()); + } + } else { + // Node.js Readable stream + const nodeStream = stream as NodeReadable; + + const readChunk = (): Promise => { + return new Promise((resolve, reject) => { + const onData = (chunk: Uint8Array) => { + cleanup(); + resolve(chunk); + }; + const onEnd = () => { + cleanup(); + resolve(null); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const cleanup = () => { + nodeStream.off("data", onData); + nodeStream.off("end", onEnd); + nodeStream.off("error", onError); + }; + + nodeStream.on("data", onData); + nodeStream.on("end", onEnd); + nodeStream.on("error", onError); + }); + }; + + try { + let chunk = yield* until(readChunk()); + while (chunk !== null) { + buff.content += decoder.decode(chunk); + chunk = yield* until(readChunk()); + } + } catch (_error) { + // Stream ended or error occurred } - } finally { - yield* until(reader.cancel()); } }); From 553d361349361af722cd2545599e809b67f7990b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 21 Oct 2025 05:24:18 -0400 Subject: [PATCH 076/119] Don't need stream api --- test/suite.ts | 92 +++++++++++++++++++++------------------------------ 1 file changed, 38 insertions(+), 54 deletions(-) diff --git a/test/suite.ts b/test/suite.ts index 1bc64c6a0..e9a988423 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -150,9 +150,7 @@ interface Buffer { content: string; } -export function buffer( - stream: NodeReadable | ReadableStream | null, -): Operation { +export function buffer(stream: NodeReadable | null): Operation { return resource<{ content: string }>(function* (provide) { let buff = { content: " " }; yield* spawn(function* () { @@ -162,57 +160,43 @@ export function buffer( return; } - if ("getReader" in stream) { - // ReadableStream (Web API) - let reader = stream.getReader(); - try { - let next = yield* until(reader.read()); - while (!next.done) { - buff.content += decoder.decode(next.value); - next = yield* until(reader.read()); - } - } finally { - yield* until(reader.cancel()); - } - } else { - // Node.js Readable stream - const nodeStream = stream as NodeReadable; - - const readChunk = (): Promise => { - return new Promise((resolve, reject) => { - const onData = (chunk: Uint8Array) => { - cleanup(); - resolve(chunk); - }; - const onEnd = () => { - cleanup(); - resolve(null); - }; - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - const cleanup = () => { - nodeStream.off("data", onData); - nodeStream.off("end", onEnd); - nodeStream.off("error", onError); - }; - - nodeStream.on("data", onData); - nodeStream.on("end", onEnd); - nodeStream.on("error", onError); - }); - }; - - try { - let chunk = yield* until(readChunk()); - while (chunk !== null) { - buff.content += decoder.decode(chunk); - chunk = yield* until(readChunk()); - } - } catch (_error) { - // Stream ended or error occurred + // Node.js Readable stream + const nodeStream = stream as NodeReadable; + + const readChunk = (): Promise => { + return new Promise((resolve, reject) => { + const onData = (chunk: Uint8Array) => { + cleanup(); + resolve(chunk); + }; + const onEnd = () => { + cleanup(); + resolve(null); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const cleanup = () => { + nodeStream.off("data", onData); + nodeStream.off("end", onEnd); + nodeStream.off("error", onError); + }; + + nodeStream.on("data", onData); + nodeStream.on("end", onEnd); + nodeStream.on("error", onError); + }); + }; + + try { + let chunk = yield* until(readChunk()); + while (chunk !== null) { + buff.content += decoder.decode(chunk); + chunk = yield* until(readChunk()); } + } catch (_error) { + // Stream ended or error occurred } }); @@ -227,7 +211,7 @@ export function* detect( ): Operation { let start = new Date().getTime(); - while ((new Date().getTime() - start) < options.timeout) { + while (new Date().getTime() - start < options.timeout) { if (buffer.content.includes(text)) { return; } From 92c07deedb335556702a821d24c92146d80fc516 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 26 Oct 2025 03:37:29 -0400 Subject: [PATCH 077/119] Revert to tinyexec --- deno.json | 3 +- test/main.test.ts | 115 ++++++++++---------------- test/suite.ts | 207 ++++++++++++---------------------------------- 3 files changed, 102 insertions(+), 223 deletions(-) diff --git a/deno.json b/deno.json index 1f65b44fa..a9fd20aa5 100644 --- a/deno.json +++ b/deno.json @@ -22,7 +22,8 @@ "@std/testing": "jsr:@std/testing@1", "ts-expect": "npm:ts-expect@1.3.0", "@std/expect": "jsr:@std/expect@1", - "ctrlc-windows": "npm:ctrlc-windows@2.2.0" + "ctrlc-windows": "npm:ctrlc-windows@2.2.0", + "tinyexec": "npm:tinyexec@1.0.1" }, "version": "4.0.0-alpha.8" } diff --git a/test/main.test.ts b/test/main.test.ts index bb9460bc3..253ff2821 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,126 +1,101 @@ -import { buffer, describe, detect, expect, it, useCommand } from "./suite.ts"; -import { run } from "../mod.ts"; +import { describe, expect, it, x } from "./suite.ts"; +import { each, run, type Stream } from "../mod.ts"; + +function* detect(stream: Stream, text: string) { + for (const line of yield* each(stream)) { + if (line.includes(text)) { + return; + } + yield* each.next(); + } +} describe("main", () => { it("gracefully shuts down on SIGINT", async () => { await run(function* () { - let daemon = yield* useCommand("deno", { - stdout: "piped", - args: ["run", "--allow-env", "test/main/ok.daemon.ts"], - }); - let stdout = yield* buffer(daemon.stdout); - yield* detect(stdout, "started"); + let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); - daemon.kill("SIGINT"); + yield* detect(proc.lines, "started"); - let status = yield* daemon.status; + const { exitCode, stdout } = yield* proc.kill("SIGINT"); - expect(status.code).toBe(130); + expect(stdout).toContain("gracefully stopped"); - yield* detect(stdout, "gracefully stopped"); + expect(exitCode).toBe(130); }); }); if (Deno.build.os !== "windows") { it("gracefully shuts down on SIGTERM", async () => { await run(function* () { - let daemon = yield* useCommand("deno", { - stdout: "piped", - args: ["run", "--allow-env", "test/main/ok.daemon.ts"], - }); - let stdout = yield* buffer(daemon.stdout); - yield* detect(stdout, "started"); + let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); - daemon.kill("SIGTERM"); + yield* detect(proc.lines, "started"); - let status = yield* daemon.status; + const { exitCode, stdout } = yield* proc.kill("SIGTERM"); - expect(status.code).toBe(143); + expect(stdout).toContain("gracefully stopped"); - yield* detect(stdout, "gracefully stopped"); + expect(exitCode).toBe(143); }); }); } it("exits gracefully on explicit exit()", async () => { await run(function* () { - let cmd = yield* useCommand("deno", { - stdout: "piped", - args: ["run", "--allow-env", "test/main/ok.exit.ts"], - }); + let proc = yield* x("deno", ["run", "test/main/ok.exit.ts"]); - let stdout = yield* buffer(cmd.stdout); - - yield* detect(stdout, "goodbye.\nOk, computer."); + yield* detect(proc.lines, "goodbye."); + yield* detect(proc.lines, "Ok, computer."); }); }); it("exits gracefully with 0 on implicit exit", async () => { await run(function* () { - let cmd = yield* useCommand("deno", { - stdout: "piped", - args: ["run", "--allow-env", "test/main/ok.implicit.ts"], - }); + let proc = yield* x("deno", ["run", "test/main/ok.implicit.ts"]); + + yield* detect(proc.lines, "goodbye."); - let stdout = yield* buffer(cmd.stdout); - let status = yield* cmd.status; + const { exitCode } = yield* proc; - yield* detect(stdout, "goodbye."); - expect(status.code).toEqual(0); + expect(exitCode).toEqual(0); }); }); it("exits gracefully on explicit exit failure exit()", async () => { await run(function* () { - let cmd = yield* useCommand("deno", { - stdout: "piped", - stderr: "piped", - args: ["run", "--allow-env", "test/main/fail.exit.ts"], - }); - let stdout = yield* buffer(cmd.stdout); - let stderr = yield* buffer(cmd.stderr); - let status = yield* cmd.status; + let proc = yield* x("deno", ["run", "test/main/fail.exit.ts"]); + + const { stderr, exitCode, stdout } = yield* proc; - yield* detect(stdout, "graceful goodbye"); - yield* detect(stderr, "It all went horribly wrong"); - expect(status.code).toEqual(23); + expect(stdout).toContain("graceful goodbye"); + expect(stderr).toContain("It all went horribly wrong"); + expect(exitCode).toEqual(23); }); }); it("error exits gracefully on unexpected errors", async () => { await run(function* () { - let cmd = yield* useCommand("deno", { - stdout: "piped", - stderr: "piped", - args: ["run", "--allow-env", "test/main/fail.unexpected.ts"], - }); + let proc = yield* x("deno", ["run", "test/main/fail.unexpected.ts"]); - let stdout = yield* buffer(cmd.stdout); - let stderr = yield* buffer(cmd.stderr); - let status = yield* cmd.status; + const { stderr, stdout, exitCode } = yield* proc; - yield* detect(stdout, "graceful goodbye"); - yield* detect(stderr, "Error: moo"); - expect(status.code).toEqual(1); + expect(stdout).toContain("graceful goodbye"); + expect(stderr).toContain("Error: moo"); + expect(exitCode).toEqual(1); }); }); it("works even if suspend is the only operation", async () => { await run(function* () { - let process = yield* useCommand("deno", { - stdout: "piped", - args: ["run", "--allow-env", "test/main/just.suspend.ts"], - }); - let stdout = yield* buffer(process.stdout); - yield* detect(stdout, "started"); - - process.kill("SIGINT"); + let proc = yield* x("deno", ["run", "test/main/just.suspend.ts"]); - let status = yield* process.status; + yield* detect(proc.lines, "started"); - expect(status.code).toBe(130); + const { exitCode, stdout } = yield* proc.kill("SIGINT"); - yield* detect(stdout, "gracefully stopped"); + expect(exitCode).toBe(130); + expect(stdout).toContain("gracefully stopped"); }); }); }); diff --git a/test/suite.ts b/test/suite.ts index e9a988423..f0b84cdd2 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -1,15 +1,13 @@ import { expect } from "@std/expect"; +import { type KillSignal, type Options, type Output, x as $x } from "tinyexec"; export { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; export { expectType } from "ts-expect"; export { expect }; -import { ctrlc } from "ctrlc-windows"; -import { spawn as nodeSpawn } from "node:child_process"; import type { ChildProcess as NodeChildProcess } from "node:child_process"; -import type { Readable as NodeReadable } from "node:stream"; -import type { Operation } from "../lib/types.ts"; -import { resource, sleep, spawn, until, withResolvers } from "../mod.ts"; +import type { Operation, Stream } from "../lib/types.ts"; +import { resource, sleep, spawn, stream, until } from "../mod.ts"; interface ChildProcess extends NodeChildProcess { status: Operation<{ code: number | null; signal: NodeJS.Signals | null }>; @@ -63,160 +61,65 @@ export function* syncResolve(value: string): Operation { export function* syncReject(value: string): Operation { throw new Error(`boom: ${value}`); } +export interface TinyProcess extends Operation { + /** + * A stream of lines coming from both stdin and stdout. The stream + * will terminate when stdout and stderr are closed which usually + * corresponds to the process ending. + */ + lines: Stream; + + /** + * Send `signal` to this process + * @param signal - the OS signal to send to the process + * @returns void + */ + kill(signal?: KillSignal): Operation; +} + +export interface TinyProcess extends Operation { + /** + * A stream of lines coming from both stdin and stdout. The stream + * will terminate when stdout and stderr are closed which usually + * corresponds to the process ending. + */ + lines: Stream; + + /** + * Send `signal` to this process + * @param signal - the OS signal to send to the process + * @returns void + */ + kill(signal?: KillSignal): Operation; +} -export function useCommand( +export function x( cmd: string, - options?: { - args?: string[]; - cwd?: string; - env?: Record; - stdin?: "piped" | "inherit" | "null"; - stdout?: "piped" | "inherit" | "null"; - stderr?: "piped" | "inherit" | "null"; - }, -): Operation { + args: string[] = [], + options?: Partial, +): Operation { return resource(function* (provide) { - const nodeProcess = nodeSpawn(cmd, options?.args ?? [], { - cwd: options?.cwd, - env: options?.env, - stdio: [ - options?.stdin === "piped" - ? "pipe" - : options?.stdin === "null" - ? "ignore" - : "inherit", - options?.stdout === "piped" - ? "pipe" - : options?.stdout === "null" - ? "ignore" - : "inherit", - options?.stderr === "piped" - ? "pipe" - : options?.stderr === "null" - ? "ignore" - : "inherit", - ], - }); + let tinyexec = $x(cmd, args, { ...options }); - const status = withResolvers(); - - nodeProcess.on("exit", (code, signal) => status.resolve({ code, signal })); - nodeProcess.on("error", status.reject); - - const processWrapper = Object.assign(nodeProcess, { - status: status.operation, - }) as ChildProcess; - - if (processWrapper.pid && Deno.build.os === "windows") { - // Wrap the kill method to use ctrlc-windows on Windows - // See: https://github.com/denoland/deno/issues/29599 - const originalKill = processWrapper.kill.bind(processWrapper); - processWrapper.kill = (signal?: NodeJS.Signals | number) => { - if (signal === "SIGINT") { - ctrlc(processWrapper.pid!); - return true; - } else { - return originalKill(signal); - } - }; - } + let promise: Promise = tinyexec as unknown as Promise; + + let output = until(promise); + + let tinyproc: TinyProcess = { + *[Symbol.iterator]() { + return yield* output; + }, + lines: stream(tinyexec), + *kill(signal) { + tinyexec.kill(signal); + return yield* output; + }, + }; try { - yield* provide(processWrapper); + yield* provide(tinyproc); } finally { - try { - processWrapper.kill("SIGINT"); - yield* status.operation; - } catch (error) { - // if the process already quit, then this error is expected. - // unfortunately there is no way (I know of) to check this - // before calling process.kill() - - if ( - !!error && - !(error as Error).message.includes( - "Child process has already terminated", - ) - ) { - // deno-lint-ignore no-unsafe-finally - throw error; - } - } + yield* tinyproc.kill(); } }); } - -interface Buffer { - content: string; -} - -export function buffer(stream: NodeReadable | null): Operation { - return resource<{ content: string }>(function* (provide) { - let buff = { content: " " }; - yield* spawn(function* () { - let decoder = new TextDecoder(); - - if (!stream) { - return; - } - - // Node.js Readable stream - const nodeStream = stream as NodeReadable; - - const readChunk = (): Promise => { - return new Promise((resolve, reject) => { - const onData = (chunk: Uint8Array) => { - cleanup(); - resolve(chunk); - }; - const onEnd = () => { - cleanup(); - resolve(null); - }; - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - const cleanup = () => { - nodeStream.off("data", onData); - nodeStream.off("end", onEnd); - nodeStream.off("error", onError); - }; - - nodeStream.on("data", onData); - nodeStream.on("end", onEnd); - nodeStream.on("error", onError); - }); - }; - - try { - let chunk = yield* until(readChunk()); - while (chunk !== null) { - buff.content += decoder.decode(chunk); - chunk = yield* until(readChunk()); - } - } catch (_error) { - // Stream ended or error occurred - } - }); - - yield* provide(buff); - }); -} - -export function* detect( - buffer: Buffer, - text: string, - options: { timeout: number } = { timeout: 1000 }, -): Operation { - let start = new Date().getTime(); - - while (new Date().getTime() - start < options.timeout) { - if (buffer.content.includes(text)) { - return; - } - yield* sleep(10); - } - - expect(buffer.content).toMatch(new RegExp(text)); -} From 28b0f7649cbf72fc6af94f08d89fbcc9c98f4c49 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 26 Oct 2025 04:16:23 -0400 Subject: [PATCH 078/119] Try terminating with windowns-ctrlc --- test/suite.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/suite.ts b/test/suite.ts index f0b84cdd2..691597350 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -1,6 +1,7 @@ import { expect } from "@std/expect"; import { type KillSignal, type Options, type Output, x as $x } from "tinyexec"; export { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +import { ctrlc } from "ctrlc-windows"; export { expectType } from "ts-expect"; export { expect }; @@ -112,6 +113,11 @@ export function x( lines: stream(tinyexec), *kill(signal) { tinyexec.kill(signal); + if ( + Deno.build.os === "windows" && signal === "SIGINT" && tinyexec.pid + ) { + ctrlc(tinyexec.pid); + } return yield* output; }, }; From 6eb26da06a464ac53f27f2448c1c64a80607fecd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 26 Oct 2025 04:24:30 -0400 Subject: [PATCH 079/119] Remove unnecessary postBuild step in node tests --- tasks/built-test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tasks/built-test.ts b/tasks/built-test.ts index c61a07f08..989a6b183 100644 --- a/tasks/built-test.ts +++ b/tasks/built-test.ts @@ -1,5 +1,4 @@ import { build, emptyDir } from "jsr:@deno/dnt@0.41.3"; -import { copy } from "jsr:@std/fs@^1"; const outDir = "./build/test"; @@ -31,20 +30,4 @@ await build({ version: "0.0.0", sideEffects: false, }, - postBuild: async () => { - await Deno.mkdir("./build/test/esm/test/main", { recursive: true }); - for await (const file of Deno.readDir("./test/main")) { - if (file.isFile) { - const content = await Deno.readTextFile(`./test/main/${file.name}`); - const newContent = content.replaceAll( - `from "../../mod.ts"`, - `from "../../mod.js"`, - ); - await Deno.writeTextFile( - `./build/test/esm/test/main/${file.name}`, - newContent, - ); - } - } - }, }); From ec131b7e8e20bfb36159a8f7938513219727ccdf Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 26 Oct 2025 04:27:30 -0400 Subject: [PATCH 080/119] Remove more unused code --- test/suite.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/suite.ts b/test/suite.ts index 691597350..c07fbf33f 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -1,19 +1,13 @@ import { expect } from "@std/expect"; +import { ctrlc } from "ctrlc-windows"; import { type KillSignal, type Options, type Output, x as $x } from "tinyexec"; export { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; -import { ctrlc } from "ctrlc-windows"; export { expectType } from "ts-expect"; export { expect }; -import type { ChildProcess as NodeChildProcess } from "node:child_process"; - import type { Operation, Stream } from "../lib/types.ts"; import { resource, sleep, spawn, stream, until } from "../mod.ts"; -interface ChildProcess extends NodeChildProcess { - status: Operation<{ code: number | null; signal: NodeJS.Signals | null }>; -} - export function* createNumber(value: number): Operation { yield* sleep(1); return value; From fa2f4ec3ff9bb504f31bcf306be501bd795d570e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 26 Oct 2025 04:29:47 -0400 Subject: [PATCH 081/119] Updated changelog --- Changelog.md | 5 +++++ deno.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 0c8d23321..3d1273dcc 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,10 @@ # Changelog +## 4.0.0-alpha.9 + +- Do not bind SIGTERM in Windows for v4 + https://github.com/thefrontside/effection/pull/1032 + ## 4.0.0-alpha.8 - Add `until()` operation for turning promises into operations diff --git a/deno.json b/deno.json index a9fd20aa5..824c1702f 100644 --- a/deno.json +++ b/deno.json @@ -25,5 +25,5 @@ "ctrlc-windows": "npm:ctrlc-windows@2.2.0", "tinyexec": "npm:tinyexec@1.0.1" }, - "version": "4.0.0-alpha.8" + "version": "4.0.0-alpha.9" } From efef8e1a25a814b69bee6773c7a752fa5e79925f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 27 Oct 2025 20:15:49 -0400 Subject: [PATCH 082/119] Add missing entries --- Changelog.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 3d1273dcc..381a1c71c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,10 +1,22 @@ # Changelog -## 4.0.0-alpha.9 +## 4.0.0-beta.3 - Do not bind SIGTERM in Windows for v4 https://github.com/thefrontside/effection/pull/1032 +## 4.0.0-beta.2 + +- run resources at the priority level of their caller + https://github.com/thefrontside/effection/pull/1017 +- add interval() helper to consume a stream of intervals, port from v3 + https://github.com/thefrontside/effection/pull/1016 + +## 4.0.0-beta.1 + +- properly propagate errors from nested scopes + https://github.com/thefrontside/effection/pull/1014 + ## 4.0.0-alpha.8 - Add `until()` operation for turning promises into operations From fa5768d7f28302f37f139b52d7952cd42b7d628e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 15 Dec 2025 18:33:14 -0600 Subject: [PATCH 083/119] =?UTF-8?q?=F0=9F=9A=9A=20forward=20port=20www=20t?= =?UTF-8?q?o=20v3=20(#1042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/www.yaml | 66 +++ .gitignore | 3 +- deno.json | 7 +- lib/main.ts | 1 + tasks/built-test.ts | 1 + www/.gitignore | 5 + www/README.md | 110 ++++ www/assets/external-link.svg | 20 + www/assets/images/effection-logo.svg | 111 ++++ www/assets/images/favicon-effection.png | Bin 0 -> 678 bytes www/assets/images/icon-effection.svg | 82 +++ www/assets/images/jsr-logo.svg | 14 + www/assets/images/meta-effection.png | Bin 0 -> 22258 bytes www/assets/images/overriding-context.svg | 410 +++++++++++++ www/assets/prism-atom-one-dark.css | 543 +++++++++++++++++ www/assets/search.js | 98 ++++ www/components/alert.tsx | 35 ++ www/components/api/api-page.tsx | 208 +++++++ www/components/footer.tsx | 57 ++ www/components/header.tsx | 98 ++++ www/components/icons/cartouche.tsx | 18 + www/components/icons/discord.tsx | 16 + www/components/icons/external.tsx | 18 + www/components/icons/github.tsx | 16 + www/components/icons/info.tsx | 15 + www/components/icons/search.tsx | 19 + www/components/icons/source-code.tsx | 31 + www/components/icons/star.tsx | 17 + www/components/icons/typescript.tsx | 21 + www/components/navburger.tsx | 13 + www/components/package/cicle-score.tsx | 34 ++ www/components/package/cross.tsx | 15 + www/components/package/exports.tsx | 103 ++++ www/components/package/header.tsx | 35 ++ www/components/package/icons.tsx | 715 +++++++++++++++++++++++ www/components/package/source-link.tsx | 25 + www/components/project-select.tsx | 91 +++ www/components/rehype.tsx | 23 + www/components/score-card.tsx | 212 +++++++ www/components/search-input.tsx | 24 + www/components/transform.tsx | 44 ++ www/components/type/icon.tsx | 46 ++ www/components/type/jsx.tsx | 405 +++++++++++++ www/components/type/markdown.tsx | 410 +++++++++++++ www/components/type/tokens.tsx | 37 ++ www/context/context-api.ts | 73 +++ www/context/doc-page.ts | 4 + www/context/fetch.ts | 76 +++ www/context/jsr.ts | 19 + www/context/logging.ts | 91 +++ www/context/process.ts | 160 +++++ www/context/request.ts | 3 + www/context/shell.ts | 47 ++ www/context/url-rewrite.ts | 18 + www/deno-deploy-patch.ts | 26 + www/deno.json | 81 +++ www/e4.ts | 111 ++++ www/hooks/use-deno-doc.tsx | 283 +++++++++ www/hooks/use-description-parse.tsx | 35 ++ www/hooks/use-markdown.test.ts | 28 + www/hooks/use-markdown.tsx | 118 ++++ www/hooks/use-mdx.tsx | 45 ++ www/lib/clones.ts | 21 + www/lib/command-parser.test.ts | 158 +++++ www/lib/command-parser.ts | 211 +++++++ www/lib/deno-json.ts | 21 + www/lib/ensure-trailing-slash.test.ts | 17 + www/lib/ensure-trailing-slash.ts | 7 + www/lib/fs.ts | 10 + www/lib/octokit.ts | 37 ++ www/lib/package.ts | 214 +++++++ www/lib/remove-description-hr.ts | 35 ++ www/lib/replace-all.ts | 41 ++ www/lib/repo.ts | 50 ++ www/lib/semver.ts | 27 + www/lib/shift-headings.ts | 20 + www/lib/toc.ts | 32 + www/lib/trim-after-hr.ts | 24 + www/lib/workspace.ts | 53 ++ www/lib/worktrees.ts | 21 + www/main.css | 164 ++++++ www/main.tsx | 96 +++ www/plugins/etag.ts | 40 ++ www/plugins/rebase.ts | 82 +++ www/plugins/sitemap.ts | 125 ++++ www/plugins/tailwind.ts | 77 +++ www/resources/guides.ts | 168 ++++++ www/resources/jsr-client.ts | 110 ++++ www/routes/api-index-route.tsx | 106 ++++ www/routes/api-reference-route.tsx | 67 +++ www/routes/app.html.tsx | 83 +++ www/routes/assets-route.ts | 8 + www/routes/guides-route.tsx | 210 +++++++ www/routes/index-route.tsx | 231 ++++++++ www/routes/links-resolvers.ts | 39 ++ www/routes/pagefind-route.ts | 30 + www/routes/redirect-docs-route.tsx | 31 + www/routes/redirect-index-route.tsx | 22 + www/routes/redirect.tsx | 31 + www/routes/search-route.tsx | 57 ++ www/routes/x-index-route.tsx | 123 ++++ www/routes/x-package-route.tsx | 241 ++++++++ www/tailwind.config.ts | 26 + www/testing.ts | 62 ++ www/testing/adapter.ts | 134 +++++ www/testing/helpers.ts | 53 ++ www/testing/logging.ts | 62 ++ www/testing/temp-dir.ts | 71 +++ 108 files changed, 9036 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/www.yaml create mode 100644 www/.gitignore create mode 100644 www/README.md create mode 100644 www/assets/external-link.svg create mode 100644 www/assets/images/effection-logo.svg create mode 100644 www/assets/images/favicon-effection.png create mode 100644 www/assets/images/icon-effection.svg create mode 100644 www/assets/images/jsr-logo.svg create mode 100644 www/assets/images/meta-effection.png create mode 100644 www/assets/images/overriding-context.svg create mode 100644 www/assets/prism-atom-one-dark.css create mode 100644 www/assets/search.js create mode 100644 www/components/alert.tsx create mode 100644 www/components/api/api-page.tsx create mode 100644 www/components/footer.tsx create mode 100644 www/components/header.tsx create mode 100644 www/components/icons/cartouche.tsx create mode 100644 www/components/icons/discord.tsx create mode 100644 www/components/icons/external.tsx create mode 100644 www/components/icons/github.tsx create mode 100644 www/components/icons/info.tsx create mode 100644 www/components/icons/search.tsx create mode 100644 www/components/icons/source-code.tsx create mode 100644 www/components/icons/star.tsx create mode 100644 www/components/icons/typescript.tsx create mode 100644 www/components/navburger.tsx create mode 100644 www/components/package/cicle-score.tsx create mode 100644 www/components/package/cross.tsx create mode 100644 www/components/package/exports.tsx create mode 100644 www/components/package/header.tsx create mode 100644 www/components/package/icons.tsx create mode 100644 www/components/package/source-link.tsx create mode 100644 www/components/project-select.tsx create mode 100644 www/components/rehype.tsx create mode 100644 www/components/score-card.tsx create mode 100644 www/components/search-input.tsx create mode 100644 www/components/transform.tsx create mode 100644 www/components/type/icon.tsx create mode 100644 www/components/type/jsx.tsx create mode 100644 www/components/type/markdown.tsx create mode 100644 www/components/type/tokens.tsx create mode 100644 www/context/context-api.ts create mode 100644 www/context/doc-page.ts create mode 100644 www/context/fetch.ts create mode 100644 www/context/jsr.ts create mode 100644 www/context/logging.ts create mode 100644 www/context/process.ts create mode 100644 www/context/request.ts create mode 100644 www/context/shell.ts create mode 100644 www/context/url-rewrite.ts create mode 100644 www/deno-deploy-patch.ts create mode 100644 www/deno.json create mode 100644 www/e4.ts create mode 100644 www/hooks/use-deno-doc.tsx create mode 100644 www/hooks/use-description-parse.tsx create mode 100644 www/hooks/use-markdown.test.ts create mode 100644 www/hooks/use-markdown.tsx create mode 100644 www/hooks/use-mdx.tsx create mode 100644 www/lib/clones.ts create mode 100644 www/lib/command-parser.test.ts create mode 100644 www/lib/command-parser.ts create mode 100644 www/lib/deno-json.ts create mode 100644 www/lib/ensure-trailing-slash.test.ts create mode 100644 www/lib/ensure-trailing-slash.ts create mode 100644 www/lib/fs.ts create mode 100644 www/lib/octokit.ts create mode 100644 www/lib/package.ts create mode 100644 www/lib/remove-description-hr.ts create mode 100644 www/lib/replace-all.ts create mode 100644 www/lib/repo.ts create mode 100644 www/lib/semver.ts create mode 100644 www/lib/shift-headings.ts create mode 100644 www/lib/toc.ts create mode 100644 www/lib/trim-after-hr.ts create mode 100644 www/lib/workspace.ts create mode 100644 www/lib/worktrees.ts create mode 100644 www/main.css create mode 100644 www/main.tsx create mode 100644 www/plugins/etag.ts create mode 100644 www/plugins/rebase.ts create mode 100644 www/plugins/sitemap.ts create mode 100644 www/plugins/tailwind.ts create mode 100644 www/resources/guides.ts create mode 100644 www/resources/jsr-client.ts create mode 100644 www/routes/api-index-route.tsx create mode 100644 www/routes/api-reference-route.tsx create mode 100644 www/routes/app.html.tsx create mode 100644 www/routes/assets-route.ts create mode 100644 www/routes/guides-route.tsx create mode 100644 www/routes/index-route.tsx create mode 100644 www/routes/links-resolvers.ts create mode 100644 www/routes/pagefind-route.ts create mode 100644 www/routes/redirect-docs-route.tsx create mode 100644 www/routes/redirect-index-route.tsx create mode 100644 www/routes/redirect.tsx create mode 100644 www/routes/search-route.tsx create mode 100644 www/routes/x-index-route.tsx create mode 100644 www/routes/x-package-route.tsx create mode 100644 www/tailwind.config.ts create mode 100644 www/testing.ts create mode 100644 www/testing/adapter.ts create mode 100644 www/testing/helpers.ts create mode 100644 www/testing/logging.ts create mode 100644 www/testing/temp-dir.ts diff --git a/.github/workflows/www.yaml b/.github/workflows/www.yaml new file mode 100644 index 000000000..76da0cf10 --- /dev/null +++ b/.github/workflows/www.yaml @@ -0,0 +1,66 @@ +name: www +on: + workflow_dispatch: # Allows manual trigger + schedule: + - cron: "0 */8 * * *" # Runs every 8 hours + pull_request: + push: + branches: + - v4 + +jobs: + www: + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + id-token: write # Needed for auth with Deno Deploy + contents: read # Needed to clone the repository + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Serve Website + run: | + deno task dev & + until curl --output /dev/null --silent --head --fail http://127.0.0.1:8000; do + printf '.' + sleep 1 + done + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JSR_API: ${{ secrets.JSR_API }} + timeout-minutes: 5 + working-directory: ./www + + - name: Download Staticalize + run: | + wget https://github.com/thefrontside/staticalize/releases/download/v0.2.2/staticalize-linux.tar.gz \ + -O /tmp/staticalize-linux.tar.gz + tar -xzf /tmp/staticalize-linux.tar.gz -C /usr/local/bin + chmod +x /usr/local/bin/staticalize-linux + + - name: Staticalize + run: | + staticalize-linux \ + --site=http://127.0.0.1:8000 \ + --output=www/built \ + --base=https://effection-www.deno.dev/ + + - run: npx pagefind --site built + working-directory: ./www + + - name: Upload to Deno Deploy + uses: denoland/deployctl@v1 + with: + project: effection + entrypoint: "jsr:@std/http/file-server" + root: www/built diff --git a/.gitignore b/.gitignore index 7883eb9be..26b892cd2 100755 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ # Local Netlify folder .netlify -/build/ \ No newline at end of file +/build/ +/node_modules/ diff --git a/deno.json b/deno.json index 824c1702f..b38a09a84 100644 --- a/deno.json +++ b/deno.json @@ -23,7 +23,12 @@ "ts-expect": "npm:ts-expect@1.3.0", "@std/expect": "jsr:@std/expect@1", "ctrlc-windows": "npm:ctrlc-windows@2.2.0", - "tinyexec": "npm:tinyexec@1.0.1" + "tinyexec": "npm:tinyexec@1.0.1", + "https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "npm:path-to-regexp@8.2.0" }, + "nodeModulesDir": "auto", + "workspace": [ + "./www" + ], "version": "4.0.0-alpha.9" } diff --git a/lib/main.ts b/lib/main.ts index 4d77d11d5..0bc54be41 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -180,6 +180,7 @@ function* withHost(op: HostOperation): Operation { // @see https://github.com/iliakan/detect-node/blob/master/index.js } else if ( Object.prototype.toString.call( + // @ts-expect-error we are just detecting the possibility, so type strictness not required typeof globalThis.process !== "undefined" ? globalThis.process : 0, ) === "[object process]" ) { diff --git a/tasks/built-test.ts b/tasks/built-test.ts index 989a6b183..dd9fa1a9a 100644 --- a/tasks/built-test.ts +++ b/tasks/built-test.ts @@ -15,6 +15,7 @@ await build({ deno: true, }, test: true, + testPattern: "test/**/*.test.ts", typeCheck: false, scriptModule: false, esModule: true, diff --git a/www/.gitignore b/www/.gitignore new file mode 100644 index 000000000..87b042e67 --- /dev/null +++ b/www/.gitignore @@ -0,0 +1,5 @@ +/node_modules +pagefind +build +built +tailwind diff --git a/www/README.md b/www/README.md new file mode 100644 index 000000000..dbb22c4c7 --- /dev/null +++ b/www/README.md @@ -0,0 +1,110 @@ +## Effection Website + +The Effection website shows documentation and packages that it pulls from GIT +repositories on GitHub. + +## About Git Integration + +The Effection website uses sophisticated GitHub integration to dynamically load +and display documentation and packages from Effection repositories. This +integration works through multiple layers: + +### Repository Access + +- **Dual Provider System**: Uses both Git commands (`git-provider.ts`) and + GitHub's Octokit API (`octokit-provider.ts`) for redundant access to + repository data +- **Dynamic Remote Management**: Automatically adds and fetches GitHub remotes + for repositories, enabling access to branches, tags, and file contents +- **Branch & Tag Detection**: Intelligently determines whether references are + branches or tags and normalizes them to proper Git reference formats + +### Documentation Loading + +- **Guides**: Pulls structured documentation from `docs/structure.json` and + loads MDX files from the `docs/` directory in the + [**thefrontside/effection**](https://github.com/thefrontside/effection) + repository +- **API Documentation**: Generates API documentation using Deno's documentation + generator from TypeScript source files in the + [**thefrontside/effection**](https://github.com/thefrontside/effection) + repository (supports both v3 and v4 via tags like `effection-v3.*` and + `effection-v4.*`) +- **README Integration**: Loads and renders README.md files from package + directories using MDX processing + +### Package Discovery + +- **Workspace Integration**: Reads `deno.json` files to discover packages and + their configurations within: + - [**thefrontside/effection**](https://github.com/thefrontside/effection) - + Core Effection library packages + - [**thefrontside/effectionx**](https://github.com/thefrontside/effectionx) - + Extended Effection ecosystem packages +- **Multi-Version Support**: Handles different versions of packages by working + with Git tags and branches from both repositories +- **Export Mapping**: Maps package exports to their corresponding source files + for direct GitHub links + +### Dynamic Content + +- **Live Repository Data**: Fetches star counts, default branches, and + repository metadata directly from GitHub +- **Content Versioning**: Supports loading content from specific Git references + (branches, tags, commits) +- **Badge Integration**: Generates badges for JSR packages, npm packages, bundle + sizes, and other metrics + +This integration ensures that the website always reflects the current state of +the Effection ecosystem by pulling data directly from the source repositories on +GitHub. + +## Deployment + +The website is deployed to Deno Deploy using a static site generation process +powered by the [Staticalize](https://github.com/thefrontside/staticalize) +utility: + +### Automated Deployment Process + +- **Trigger**: Runs automatically every 8 hours via scheduled GitHub Action, on + pushes to `main` branch, and can be manually triggered +- **Static Generation**: The live website is crawled and converted to static + files using Staticalize +- **Search Integration**: Pagefind indexes the static content to enable + client-side search functionality +- **Deno Deploy**: Static files are uploaded to + [Deno Deploy](https://deno.com/deploy) and served via their edge network + +### Deployment Pipeline + +1. **Server Startup**: Spins up the dynamic website locally using + `deno task dev` +2. **Content Crawling**: Staticalize crawls `http://127.0.0.1:8000` to generate + static HTML/CSS/JS +3. **Search Indexing**: Pagefind processes the static files to create search + indexes +4. **Upload**: Deploys the built static site to the `effection-www` project on + Deno Deploy + +This ensures the website stays current with repository changes while providing +fast global delivery through static hosting. + +## Troubleshooting + +### "Bad credentials" Error from GitHub API + +If you encounter an `HttpError: Bad credentials` error when running the website: + +1. **Verify your GITHUB_TOKEN**: Ensure the `GITHUB_TOKEN` environment variable + is set with a valid GitHub personal access token +2. **Clear the cache**: Old cached data may contain requests made with an + expired or invalid token. Run the clear-cache task: + ```bash + deno task clear-cache + ``` +3. **Restart the server**: After clearing the cache, restart the development + server + +This error typically occurs when the cache contains authenticated requests from +a previous token that is no longer valid. diff --git a/www/assets/external-link.svg b/www/assets/external-link.svg new file mode 100644 index 000000000..291aad217 --- /dev/null +++ b/www/assets/external-link.svg @@ -0,0 +1,20 @@ + diff --git a/www/assets/images/effection-logo.svg b/www/assets/images/effection-logo.svg new file mode 100644 index 000000000..2fb93d1a7 --- /dev/null +++ b/www/assets/images/effection-logo.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/assets/images/favicon-effection.png b/www/assets/images/favicon-effection.png new file mode 100644 index 0000000000000000000000000000000000000000..e0c21d7f29efe1a8c58487255f6a2e1f687d32b7 GIT binary patch literal 678 zcmV;X0$KfuP)500009a7bBm000XT z000XT0n*)m`~Uy~SV=@dR9J=W*iB0tK^z9~Ct>rVHAZV`eW8daEsEBI_uP8wrKJ?n zlX&yutrw3)`~uzt4-%w!(=QM&UIe4kq8=rb(ihssRNKUuEfZ$enaS+V><}RbpUbkd z|IGXtW@eW}T-QabB-%2PdrUxD3geg-KAoI-4j>00O`DDYWD5E0Z>g?})=ki+BCR{r zjbSu13JB<6IXbu>1oSsK+DBltTiXZ1r#?gUUWV||&QKu60K)Q-UdJ?bfgoRu40#rd zW2w8xdwx-?;oZ;g^{dcfvJHvZ1w^0pdu^4SO;nHfwIC%%1<5|{LnTb zX*lCk>{i(J{7AyCF@>-U|N4RLlU;PiB1RxCX*#xm5w>p_2+O0J3N|tijX+Y;GNvgR zNjtV60aN53l%(^+C=ZhLDjmoz|C5`JWK4z!)wgR1P-LhVvBlTky&s4`${VXPBqLzx z_VPd{?EEkQ5lDP_9uYb-JZNR=b}2GQ?dxX&!ruJCny~@-L0D3BW^|ASOjwW(Vmc$f z7zyO^Ahah!L(m8*svt1Jlhl#>(cwXCtt}zd(H;z-DgC*^oB8+nbau{~{*uGJ zkk6j0>I2W2LO%Nez%-j7(kbbj4AN|f7Gn#tff!*VP*n#~K#C?1n0y2Ck>Bj{8d5-% zyQV1^g!?WI1%b_rQa*>81+l^yfUF?am;jJ91U75tE_44cgfamB1G=1|{VoShApigX M07*qoM6N<$g8DNlvH$=8 literal 0 HcmV?d00001 diff --git a/www/assets/images/icon-effection.svg b/www/assets/images/icon-effection.svg new file mode 100644 index 000000000..7a63b9cbc --- /dev/null +++ b/www/assets/images/icon-effection.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + diff --git a/www/assets/images/jsr-logo.svg b/www/assets/images/jsr-logo.svg new file mode 100644 index 000000000..abd8f5fd6 --- /dev/null +++ b/www/assets/images/jsr-logo.svg @@ -0,0 +1,14 @@ + diff --git a/www/assets/images/meta-effection.png b/www/assets/images/meta-effection.png new file mode 100644 index 0000000000000000000000000000000000000000..6268ca9f60de10e2d1a708a219cfa0d4e43e110c GIT binary patch literal 22258 zcmeFZXH-*N6F(Y2MLLQUsq!GA^xm6dM~VWW3891Z-a-Hs0)hw@nsgCS2qp9uP(o39 zko{|8PIuweGh!Yq8EcVV`~G%%1tp%x}-hD?|NzO!VCJAP|V@ z!TsA$K%g^75QsYH{5jwoH!QjnIM8|DH}?a97%ravQ4LLpAAvwuKo4%;G!D*Oo2E7PVD$RAvi#2EN|I^L(;DtLU>!mw%-s87#!s2p$Zbp~amEV71P4y#au=uf1 zU1Z$(^K_qP&%XL};@pUNDj1piU0`B26Z{{9*tKemAo_1Qud4$e`d z9O5wJJLjHm{Cr8G&rw@I*`hWAc)*IlM7c_W941hw@k-p!xi4_&5~Utt(k8v!dgJ{& zmrb$KEcQNC%AxJ}wsW^d_zsRIdIh3+sQp1Xa%TsDN(Jmieo<(nM9Q295k@l*?!Z5+4hE4CTSW&f3jQIO-<^>$?Px1gkv zSdf^|PawQS9?>C}jf7|rk5DfVchCLj`gved*N?#cU70FQ4`~r?SRhr9t%A+yuu_ zf$t`r1*RklwXJ&JpfyWBd4c%w-zQqlFD@)Byk{BsC6v}Iqom$xEWD%|9q3kOv>$?H z=#POy!UWaH{Sg?)<9qy7rgh$3pIck-mu*T88mwBmEj@TJV}BG-;S@qaB0QqEMePWW zqjkmJ-5Ur%vkx1vsJ)&pS_}YGuPt)XF5qkEIWq#@TB^xwnH$<9q)Zy`Hbk7*f`f7J988tR z-$gUN7dv9R%5KA_=-{Hfr@&)MoESbK-Oe)w%Uz;I1VzXrkSno8H7EUASnK6v6NZ}{ z+K1*fYXY_>N5Z(11Krl`h5l$YCn@pYJ_i*MQD+%aE{)W>jkLg7X09*_oUFFCbiTgK zfx(BHa4C5jbIHsu%;*$1;1lD~G%T8zQ_6w`#e=?$1$WX3%w3yMQD>%13g56`wAon@x@e zWym{7fNea8eY%e6Grpjp+o;3WY6Ri2X}YEy*zHaFMG*DtMP(5S1ON!2=xR+$*1o1` zsX)w-8V4U@#wpx20+n}^=jMgPf=3>j_SIEynv7$}hu@}5iImYFqo(5=@u??RB`xrx z8zgY%=pH%(wz6uCFPUExHO+C8Aehp)qbl;3CujBz?>KsX=CU{@FGA64;|#zbOSG_V z4tFk=hVrG%(LR=)retgGUNnp}F6ke{qP~m5mS%*Q;)+z>}}&q8w0sCT+!t_%o%W0;P^kU`P0LHszq#7c>kzopkE+Z zjUt#Tgd4#lej~ocC%=iljZM2R9zeY~uDMrR>)@ZP!A2#lO-R}95%APKa|3X+=3Ye2 zr&RpWglQSOW}{J%(JFP-m+g+{gWG^3esbqapsxUID?0Vsi$qttii$#x8v9e+n(1x! zs-9W8V3%CoSGql#e$Ra{mCI&Y_3(rWV~W+=(C!9bv>OS?qB*HiPZU3KtMeeepvRb6 z;h8dVCgJkGz3QK9RIb%~A7;`*J+yLS>!w(Xn=Q*eFYrj5GnI4dW0rI3xv^?BLU?8T zURXDqe$*<+87IcA0`NL@S$dYYu}{TM~6dk=thohpN;{FSKpD!>N*{wfqWkM@T5j`ia{`Q|GhK1 zUz1E8IPNl)FR-qaf_V~Q(<38?LB(B~hBoF?*FXz^b4UIL>^uJj0zeD^Wwn=LHA=!a z(KGC!){fk;Nd@P=PpzW^d1Z`jA{)F|VyOl_`a<5|B9?f6|Dd11(6YbKgtgVBX*a}^ z%~_)H5j%!lHG>12Bv=hIA$T%P+JCxw@+8>o#Bj*I>gM+>^eYh*3a7)g@<^tMydhi* zulM6@nd-hqrJM8gf@JgR#^xMkN&rw#h%%|1551WHuOe!ai7yIUt0Tf>d5;f*n5|NQua#X9>;D@nT(po&BdL;glHWFqqzUK3ntJZQ5L!*-X>-1}EYBe?EH&3)nc? z^&_Mr4#=Au_RSoN{g97$^IRBw`igg&H{+!b*tBl!wz{&*^fc_RLR)bnR-8_~;Z+_i z9m|a+Pn24E|+dSLf{2Y(& zNe@!J_-$OGlK*@suZ+ggHW{NX@M)nR#ia=wB(xSvlC1>CT<=v%D0=T7lN-Cus!K~a zgaL#HEFRG;!M&yA#}R|pKQT}9n_;8-@a5#obcOO1WxsMAED;$FEX@64AY!_{L^%Cx zi1@mQlXu=nG0o!@s4}wObU+aM3k27|&3d~?g#)3~7>IR*!DO}hS~3y2;}qTP6@2CS z(C?YcT9%hmG$>_(#T?o>fXtt)0P8Ne38w!Wo0FQwzq=;amx%)5FY!@>S<)SI{!B`6 z@Lt{wBa7xhHY!<`a-3g}%k=8k&zBC6r)({?R?XBbK`(3H5CrqM)Plr}Jd={2L?&Nw zp!$se0-3QqUv2^0|6?<&Zf;b_W%2X-U+J`5GjK0o9*h(CnU|hUh2O+DRtm5vNd${N ze+L4RkdfI5!1Nh8916JpF4M{@D$Kl5^9+d!?1bsvi~hFw^OHaphmB)6sZlHDTWS>~ zDWHSM!UH~Er6CAraN7fk4MUQWeg73hh*?k_zb2;Ynb?*WJ=_vtL@g{RoW*V92tH@- zH+20`mUzSoB>D~`gW}YSb-A8X=A~nW; zpF+MfRWftD#y}8pn{n%letkm#{Uga{(gF`!hhg{k4Mn0_5L`F{rkF)Na-CW1Xx@O> zbY|i`YjL!HXk@a=(uP{AAWNTazBKzq2P%1tUQ4q`Puh4mo3g2x{%-hA?_RYN4lsBn zsq?0gjj-$0NA;ZOXOKf3i|C@kV*_>0=99w$2`k#jX2drWiI^BV6#k%$d+1OX!I55g&=uLubU{CCT z#;%EQ!FvAZh2tXq>M~TptaPhfhVt9PO&m`Y(jFcoV-M#He=}pR)}LKj5z}^bpi2+Y zO_3)d_aV)jWNFW-p99m!U2xCT#kd`tc5AcVie3y4+%_XHW^mHoOu1#Qe_98_%)oIX zAr9HPWp=X05FH_9w!2q8ThGa3-E}rmo$pJ3lNPU%kNYI=x8ZP#*_dN|o^9*mvDOMt z?#OwhMI@-$>jk2ZuA(&iWaZ4mw>1J0_<4P=7gSl{+ZSv{xANH}ZT(=b+YxZ94zJ-r zqAH~|JzCH|fswiMH9!Ap@MzuN%5ROum2LQ?@7LPtrW|SXq=h)wc`jzc&sgZDs#?q{*% z}eM&o|m7T!P)-W1!XYx1QvimJW8(sOiYd=>J{N|vA278hY z6W?9st?xd#<)C$B(Wr?qoFyV)|IgMTd$UttCR;PHgonY_AUk{)ZHazNm_p59&E`51 z#luD>v-~(lG}_{*Fvw0qmK(IXp+GmA*_0iALGfCv>e0Yxv=B4QDsRa|;cjB69+$={ z6K<}vS@39)C4!C3u@w#S2!3EgpGyP++i)N!Jg;{Y|WW$HZChKFi@1J|;s3 zKRe+^ktW$(4(USOyyl?xFR&d`re}#`JF7gyZy0ja@S8={h$n0!Zx}|O`uQq{M_NB2 zR@gKj_^P&t7=>e!K5o=^e%(Y9TGYbahq6s*%?e1NXb#-F9T9kcn8Tj5!*unk`IM$r z5FXNcbT9iCOfA8G9TmZ%q&a3CK&_g}D)yKf(baavZRp*h<+q>3KrOW@Mr!@2v6MGu zNwgwN2_5~R6|nROw$b2?I10xN>1sVmGcC@BGs7H}H};j_#bqiosoCJq&KsFpF&4phG%3x(Qd1z;}NZqL7}_Y@+nHS7*0!UywXMw&`( z(MsoY)VDmMH%0rR9iQy=ZN=j^`l?H1zLU43W8gH{p&1>sRgu2pfM4}qEMAjP{L@-F ze4;M;U5jQ06vqARV247VlZNBI-~t1T=C|1QiGVEAKmglR=0D~{_aDu@m8Un#1CoI1 zJxe!>m@5p+{_&|t8hh4ez^cT0548C(JzL>Y)XN|}FM5gKvd;2Z%)=FqW?sS`l%6bo z|D@4gUml)rA6qT^T~*w@vG*>MM@xTL@ZQu*#L8%If536CcW1s>YeA6a_^AfsS;Yj2 z^yL^!`-Dwmf}%$y=ElrpxiXe}EN;Cz&=M4u8#Cr#`4MyBB~apLf|avDs=ba{`yQnV zo(=%?{}@hf_J3r1-KO4dIXT*tR)4BKZ$X%kiLSiH!#y(jU@azy0h%zQUQ3)&4|--s znu@P4X)$jOoSefoi<}IeN)Q7`Ff7&Bya*B_ubtz8Zz!k+k7^z!Zlt+QzeJe69+&Yip?v%}ddjto7N~9`{S7 z|G7e%^7EtTv?RVqj<}K0LO!031?fTclBFL9i7}lii#xX)H1?)cdA+{}>Uq7-!d)w3?S=-ooxKH8r!54iC&15)kDQ*{@1bclhXBlOJ5 z6kZ}qYQBZ=`IzAdgf!FCZBW&VedE@qunoU^R|ly@vUW+@ke=W!H)E`0bTw zli-WELTu(EXd-rX1oxAsW4>ZT`3JPI!G8mSUQaA_nIu!K>&7O>LJ1J#)EDk87*&q} zZsHa}OF|q`2;@JdOs<4i?8hf>p!f>@NU-;C??Ka&UZ%3-Yd$&kR~)fB_U*PW#L&gR za6OgGVvGt=PyCjSgi{q&j(%pL2lAd-d4>Pk(Fy!T;JN?3tx$KC~kdC|LX}&#SlWbz+C=10 zI5$z3`XoFV;5Ek`gN zM08NL{UNq|PHcFPajL(A1!39t#Sy!q6l&s9y_6L=!tq zSwG(t6oR@1lc+=hwFPpZ`Q3<8mzS{Qi zHY1PSvSBe#HdOJHfC~)ugO_iJp~V7u>NJd)DozeMC-Jf(m4Z)4O%FMe~@|ZU3%LYk9(|R&wjw*_B5dnvSy=+kNtZ`p*@7kox5f=+IZ@D;~EH zQDGrnF}L;xCK~A=yN@&toLz<8_)!q^0zHYJt05->xOSd!i=9wS?_H_@o=^0E`a*5* z*mvyC19Auu!QaNe8%GkY%mlz(kZ8-76HbG%Y-| z_a^3)>eKSyoHDb@hes>0VDr}&jOZ(@)3(qbp|*S>1J*dd!lpt`N9z02v+V~_X7e#j6&3Uk;5Foigp zlzBOwQaj3=V2LwkAflXIu;zOa9kGd;JMfEl^J~{K{eo7X3;9{@(P>4pY_B)cVEth-13GY9=8*B_^lOz)e#cBnX~8V@^{X1k8N<@65Si zqZv(EMynn3h`rmv{^p^6KyD1wd(>ylVclW?+i}@}t{fBzM*2U)?{=h9mI^Ok zzhj?Z7O^f(T(?}P=mo-86i>=$%aGhTAm5I0J1_Q;yNt zPYdp<>N!{PO+xJ@LhSBLkE(d?!UG`A{Ilh)IDAP~cSDQfk{7PIMRR6Nlp2sguaMR} zff=9_&Q}0BKS+}f$3MlWMV#0{U#G~47qS+g&b0g;7nQydV11!4#8MMYFNCIlehIkN zu<)FDFTj?|{0!ZR4JZmN0Wstfr(`CRZdnW`<&9XCG0vB&2|Q?)NYOg93%(KqxRD$8 z&hORGwDR@=@1D8@0PklPDDM6NAh1xI>*FjA2L-k7U12_{M6{ERD3lxzC?#U9bEHKH zQx*X$)3~QQ;k0`#K#C;M0+T-YQs;-At49R2!Asce?i6m+hHy3dxFuiCed^}=4U>G< z9N4YUH>)`sFIs(j);!fiR=p{(B&=g#R5TeRwrX807Ut6$iqlY{pb!gZ&4@nRF(;M% zME~g0AES_te~P74H{%rrnjdQvn{tYTFXtASR)%_rxm1M%m{fA0!<!tzMF2TbXpp=M3^kLi7FB!CD3Y; zSvmU8L94e zcD5H@JaHoB2S5nr9NZ7h+}Ey^(HA!P+*YPI`5jWNw|loL4wULJ7y&yqF&+w=XA=Me zKyH7Wf_3zaldeiQPx8Xq?wVTrI9KS&;NRQ>s+W<;eU7#0v!wnTP8tE6<9DYAmplL?RlNiZ|<6=ksnS;r^Ykp-)PNn1%lDA3pP~p z2G%HKv;U8ta`$nqcjJ}L2KAz0`)JM>76WpPbbR=Fy@96A*B0onl3xnsekTb=fPMrZ z#8RV;8;PC3W!8GW)qVh4)rlQ(ve5YH8VxGeq85)_oY+fHo3A)t=%!p~-s*)2BZHj# zn&iZ-wvBN!ev9IvH+mZCHlf#RmaqU4Bb6{gEq<1=9(+r!4a#?8=f023GuTiYUOx3P{dtbDn`fWD@tLZN}4&Tq8i5{;Ma9urlh58}cwCP-Y ztO*CWsi%C*D)YB&If~chJ-eU6FWoggnJ?Y^PGfL&a4d&H_NecPX6LJe9`4$xE=gEM zU6^(gHoO=)aUMBgTzuBNgu~_-_bPSrcI4S4_{N55w9+D%Lze$`!Ksldm6R4{`kLpN znnl>Ll*~$t6$mEDV+S?@e0iSK*|js^ru-tBsjy@iR*S2kHyR z%csS58kqwC4U9;et9{8uhiW?(#9=ns8P^CnxCE^MZ;oY%U8H=-c7YyUOrPdq(XGT{ zUk6)EUd&UjFA?}_wqRN){<+#B_<&X*Og1xkXhhF#Drfr0aKE+cXmM#GWEXC`4-8Sp<7nJg-}dAYmP@4!S-E2`x1j@ulqi<)WlJU|I1tjN1Cm+TUguddBBRW+#F%eP`J09N3ASIszm_X=3r)(!Efc<+n``pe9p}0 zI?hY##Iu(QgAW>04O?i@1lRgfl@EPpX;v9c0WP~qo5Kn4!tbE*raU(6-K~h@ncZk< z;#x8KUFtP>pw$1oh8gI9#37EN&9rze7z zr+*($%H`6izGN-dX}f|KgY3I3udWs~w95Qc-Or;%$0dxG)el;PT->XaYWTDWa+~MK zfL~74FR@Gq1)K9eNpto>Y(A0p-<5&T%^WRK7AsD;tMffEJ{GnlJWe`#XIJgpCn=IMmR9vE*X@sn?Fpz6xxJkMD z4^pW<#G;V0Ge?*NT<*($mP+T*r9}t!XoPTlv{}|a1T5h7 zzp(vBQ$*Z*5eVA`d2Y6$LJXaYG3xhHNjC1j_FzQRJ~yXpacG+N-eKL{xA0icty^Fqkv$0#{iBJT~yXsT=Q z!g@^6uhadCK0;l8y=&oYE!JT^c!E3B1~|`{tj+{RfGFw-;Q1^>>Ij|gOQ3UFJn`{S z-umFyDq>Ktcyt6mEVk|+>P(H%4Ods6Z*IiJjA@l<483Omuu zh|Y6s*F9NVslk;e*KxPi8>BGEt&2P-+Bmhuy?4N1I0nX=1X`Qo6L>o1Vo zwn}Q00OX+fBS!Fh5%yr198P|0dR^#s$(6G8)j7!VxZVC;93L$qUB@GDD_nJsJjT*IdXSu4o>6m+IgxBU@5tG;Xw|xpn|wL{3Z<4Vr!6<@N`2|HnOpFr)nesN11* z=|w+ERo!O5Msy1^bWKzAHP#8eNgho&uM>mdtC9XJbmE+45zaJF#j^409dES`)b=ug$zlHV>4%I5d4=CmK_r)jP2xd8_OW>q>JG z=#eFutUGEGxmjjOVA5cKvcO_B%B+!-Bpkcfgo?k%JHpY`5S0~#R}|1gVKO{%)~xzq zNGW_`3E$0xY^aD}Y+iPytoHNIb)$R32r~dG-1G;gnaHg3nyg^6J z7N2xr^KHkiQz{X3EE2&@!x?r8Iboe|02swz0~dAdA7$K)5(;mU7Pmg>*Z(RxFw6L2 z!#f~-4fcPuoRn}IMV=iXH#9=n`%b3b+Aa@Zc)GG<rA0b{)#+}2+Gba7qW~UbED~ytPaGRz(krIZ!TZZ&ki%}Bn*zxbUSRc z*+wJpafl^g!;ZaY)lLjLYda(}N_eox4VX+a&9XSqtZ~L%ip_?qDrdi{m4%P;<;`^E z*0U)1g?Ay{&(#3t_DK@^(hu^)FUkdYX(O1r;92u9X-fX}w4sy+P_BuCFPvuU6XP+O z`-`Ux9bawYy+nSFJ~hhnnTNL?GDDa4Z>5cVqo=~$09e(W8LwcAv((oG+dif0GoPl* z>x8w+H)~(7i1nQf>@bf>r;XRl^6i*ZmHlaP*=C*nt`{<#y5K>#n^LHwvPYtxD(Z4% z6dlq`Pvo3AkTmoGlt%tcz4PR#(m69oMxZ;FoRb-KfuIS1(Ys%__$ng+RyRr`Ov2YB18fZLN8%g8&zv$qL_nc>=#wc|CG2!T>n z0*DHHS+j*uF75A88>b!GBsW;5|2NOi64NK0^giq4q|OPvxb(eYw<7U;`Pcir;Qa(b zvk2qmiEK7#3#`ASbyR=4lh@2~0s$?#m2@_VTabgsb?J(3^ z6LticHBYnV`j~Xs{yDl2aByYsvRe>82 z`OHs22@|K>V>%?4vD&FdK(anVJ6^E4m$usv*en}NF|S0LB{CyU2Cw-PUek#wW&0Ag zmE_&!jOB(iK3lgoS8EBJ+syjr#Y}tB`SZ-f;g6b>4qMx{0R^>FMW07r*Ht@vIxX3E zw~@psSEeEH$Q@b8`G!YHHjFZq7!}m)ws+>CPGc~BGU$Bj@jMMqg4TV;SAn}`umRVi z*?ez*wQzDKPAoCqb-@oBo7qdpwA|1_{VrJM6;o>b4t3ox7Te`ab@&` z@j+I{D@?bcq^$&L~TOGAR zBi`9!$`jyG{bAmtoA0FrE@^r%zoGl^^*GG6HlcNFv})-yMcYm0dwN{^{}Cfxzl;BD zup?i#1v<_ao7n&}HWp&2)Yp7wmnP#Em?mSdpVngYz-U62ndkNMkALEvZbX^qOlXDe z+CyU?M|Rfx2T~ghESmccMi1GbR_oQ>9JqC;7hfPYoUQXVO_*(tUMo%u_IVvo_gOFY zj)}#MpGEf~6+-d7tqG>trnRzE0~WK=Xk};ckww!y;csZzJZ(aFibwWqPO`TaD>eH- z4S26!_DRFxVp^IvtNmBxa4D7?@Wzum<*Dt)=z0#HF@VSFQ)6U+u2+PNxwUdI$DV_( z!|_qWfs@xWp_34`BCNJWLuirSl`B!+b6y1c&hYIQn`IHljjM}hWzgvZlnY)D|86R&I(T$agJ2i!9&-X1 zvR&ubTCva%Mm2=~^)8^ZPbypgzSU`yqBpR~sRMMD6%a*Z2~M(E$j80^=5-%I4_kU#Heyg<0@(M?7R zv0z1b%L)6M(BO7{%fDL#SW^Jlf!Q9g*}csOJ!_t>{8WZC(A7rl#KjP={GFl-n8F10 z5}|&+92j^7b}dlEl$f@rVE{NTATi(-zNx1r(5ZwN0D1+VF#JbCizqYsN1~?=0}QlC zWQ~Wr2L5;8)wWN#kL;%)3G^QZ&>>yo?$Vw@0Wc$g`yYW$#Q$8D!y5mkOYJ96V1Yo| zP*%G^|B%0!0Sp8>1m77FPQebiZ2eB`9a$ia_;<$R^T_)TOa3zluuC035A+uP`!84k zad&rjtmX6a+jfH}w*I5LbpfO%DqT)=>1liC8?{By;RRsAi4VY4068Cmi*xgm42uA| zK#SuVkJBX3mU~&Or_8AB&IO*yx4_VWj;PZm0bT9S{P$=Odn%Il_J6+q-|7FGHZWE3 zk#`$OaIL8N9S(T?o+M*DKkOg%qn~rp7v0OTN#(1?#a(QWx~Sqsr~Mrs~fqKFyPww_eFixTJL8;+(**??Xu+C4A0!iq{o}CvZeHTUjNZ|ZH&~Tp#k6~$N=na3BmE{EQMJw6S?MV2oD!p? zv&KT!Y7TbZ#PuD0GooLZ!K*wDP)YAF-Seq7EJuNc~e?o<#D6vo^+btz2`WC$7Lp_hu@aF1gQip70Q?g)O{`<3`Ks!6vEm^I}F8{&3hM7a+$1O zqpc@F>kj}$ec8QRn-sVf;9&4sP!X8M~!oC)NFYaS#ANL5>0Gt#ftBP}4R# z`?7jmO{-zy+YAq0U=57uJ`{P$Xg3{Tpsk3>4apBmJY;s77g;qt=I767&DT4e1e0#}Kj(j)RH%Fzx+UFe1`lQ4Dk;$S(agLB zoo@85K4!~FO5@uJ;c|~?pZkQl(wx=5653Q-(^YTP7l;p;IztgKE=eccDDg*84Y*io z03Qp!pXjKU*Y-jiY)W+AklCc<6VrCvD^e4c&cm!&J8Yl8G@eV9YH6|~>m-v)^Pg5X zYhn?PjtSkS8HO!V<3{sjdckeaVP6w6Idl#EWJ|$FvY~0zsEjOUn2{pH^Mn)q=|`L2 zjf|H?m_SL>{JxckZLq^+U8fH~6+Y~TXdxwdT*&!kv2s#$8`ZbsyJ}SGl$lx*?5VU< zM;D+Thz;9KM}>krV@i#AlvA5y#Op#VMsVh+lblsIRBx!kw}s$y%a^VN()t>yesgWI z5nK8sExdJ2$oeWL#PG{gOmO96Pafyz55n|Tu1>FiPJx-Z4BPjKT0qEqTnA+qr(J)|PZ>&?!#oh0hGwx2Kb*jU|(u{4wzWaV$0tX;&(7^vc)!uSTaw z19#LtZ)GKB4ecC#l$}HvyW?kJ$##S?ry|@f7W#iajj?cHUM$3#)KaQF^vD6>D%Lh6;JLkf1|5 zH}GkB#Tk4F1MAgQdbuU$S$M(r3Bn#3?5<$cgnOfu*}=It*NmyyLHKJWGq=3fa$ewhq?R*G4EFL!_G>f~dXsUoYTAuttL5vjMLa@k z6s>rK>IoQ#>pzg2F|t?*rO6$SGSl{)J?qybbN%?XoHe4oEIDEZS^h}kIXDcq<((4B zRMI7ReXbpUIY(J^yfdIPerbp&B|Ikg(rUTv?bmq^w(bOPWjcmbKg&01WU+6&RQt?5 zRoTC^?9Uf(HuBKa^>!U92AvSV$~GH*d|(!!lhRAWjwaZN>l|5Pbc8l)hj_}=fA8F6 zs*dXndzNKy*^VS92LU~ z&D_g=EHcHdn}lX|W?)=r{ALyBMzz;jDWvrsO@OdOa&~H~E@YN=LfuxE0wLR~p11ls z^%*g7$)qCgM=PvtA3|*lwzJ=n0s9mMR;siM^&mFK#>wyTYpnAV+>46w=U~!GK-X-7 zu@6f&B2{0d0yf-71y(qeu6gE@7`6W(O!(&Grh^;Sn)Y78++%ZCSwxL ze|Xs6=``(<%v>$H`}9U7YBv1UVda?3VWCv=BNu@^Js(RZQZt5b$zKC&X^ETKz!O9G zlmwUT3#-%%b}X4JYKpD-E7F)e9^Ih_K*}3Ml|=tHuKpkcQRKS1RbeL2Ot4J4p0~xX zGsQ&kis4(Q>&-H;eTdC}ls7U0!x5o?oSF*aY+h4E>4ydT3S0Z@1Scted^@VVLL|!M zleBZU?XzBq_k|XT2X$U?FJqsR3?f9=?^NuSs->XZMKVtsldJCwYW=>N6^HWe5JM*{P%4Ex!^?h+gWNuAAK(TB zID-gq#(+lN0BU;12sH5)xJzB|bFRkT&Uyj6++@8XwSnHlu59xq`ii&@ni8eyY#z1o zLp+JFz~bd#Xr<6nJH||=FIwH4cLF;zzNA9|Np*Gz}ls~OA=WKlvF|Y zZ}OGYP`ACkOOb(B+o(T^TK9AqNY8nr&ng*+g%P}d-f6mi)D^THL2p?c9BNL`?sq7! z7V{Xk@H|sHv9`cdeR?+*tqHZZiD!*(>qn6m1)ZjH@B<7!Es>zst~9hYxPQZ2f2nYA zDXmYdu|iO+7D+%rp6trWXeTh^8rUH%y*>Q zoQQ=c&)!cY*Q|7?wG>L?*QGXZWGM+R!0N&u82q|5&cj>rdnZi*NC1l`w34ma`c{tQ z$y1(z$J?rbv!Qd8L32Wpdvozgjj8&)^x=T0@cKzM^}w6mHtKhmVmLak6qo(d@6L*$ z`IE7XqaL43|cM@aeKbkHs(+?PH?CKK=I!<_ex z6LsX*bdz{n!UDj=P^1WW4f)}$@z%?N=ImWF)!*g**Z%<->%^YDDrLRT5o7j^amJ-4 zt43g?SmGS_(9MvClm6rok$G~}cko0EaP?M#8)NDm4JWsX`q%9B>gB=ICBy?q>VPHx zaBi5VQe$|CfEv=&ghcUG41sP%?G>;!IR7wpojBGwo685pn5+GIPiHWM4H(U}sbZ^V z8GT*#db+hrLD)}EYfX!XaN3+OXD6K;E{igIP3%_=7QYGCodh>YOAY0J?QfjeeK>Z5 zsZ1AYmCP(7&i5iM?%%b!C4R*;q3OrEd_iKY(!Iy)+|yr>oZ83?uST`e^$}5Q9mk&a zD$Esyt(~#F8xi7**HSh4pD`sK${~DYx zo78HciiXdZN*->SP6}kIb^o)CXd%sgJ1}u-fNak z$6jqjwA8uv=6i;jL${Yh$3~%ZPfLfkzsQ;<`l)IqXx18ig+G@k1PxvBLGhcpqwt9m6jTVa09~`@7sKST_#!lRn6d7Yl_=X(cqh z?QYaq&8ySX6%bA8CNUFGsE1I+_R$aLP$6+_%BdvT*iI{no8T}HOyAa_HsNwX=g|lH zJp;yHaie@M+dja>mF6-%qjxI!PnXalAIK8?MDVZxk6#0=cl$C-6aH#g3D7X-)~re+ z*PEFcAk&FvKs{_He!$uk9DUpP`Ho?9DE{E2O))N@ME-TG5^XzYH?pJ)<@0J%-EOEp zx~vI!rLOj##)q0`jAo)tF5sGV&hCbBsr6pN2mN2%1r$|{_emi@m* z)|ZCt1B-hVSAvxFSCTq{Uw@diMrJ+KX=TGk7>+M^MBU9CE(x~A=;ZgWJB^lIpa@)| zMDRkTlWU%>9DMxr{|J4!)-dNZQ5ZQxsa!#z0`u*9xrm;E0KZy;{$S7T78t;z{+LtQuT@%mS-^CQPLV zK51o||E+t@N;BLQq6&F5g|*4^s>yPiderH+DKe~YxxOf$5|S$0w>9?0C%u{E$5=Mo zaA>rC@=EDTL1##q)&q}VU!lGgu(}jJ55OLJz`7|fhd^Jef-Ili&-u?YxJ~41^H<<# z3Ua7B;}?Q$xmv(|caT11{G4J^kgj``AP~pv)87U7#7PXH;Y}6GN-UHrKr>awuLjR) z>;XBGAWKVw`$Neb=1z|`hOt?!y%KiT4Xy&&2A_!TjJoweJ7 zSF}B%vxOUH?mb~OTmUD9(See+UL z(%HHd44~PE0NbqIdvB-x9d&Ht+8Ks}e|(IQ)ATV;v)o>BM^LnP`r6`W82W-E>VV3p zs6k~-O#byGB*b3EyH3WeKh@AaLor`tRnTpC)?iL7{hr97e@kXYU7b7cr_)kyLhNVP zt(lWH*hL8EjD2aiQWFw8rLdkDw43x6yt-=Xj_bB&p(U{i z5py?tvKIld?$n1&;>B(oz$6h*{zd}H)AX%+`^CNyZgMHhaO7-6(}P4#xDQ|QZf0EB zWtW2(fnLHlJ!Id)IZvPTVBAr$z@u*mZ>P54f~AF1fs)uj6~~eqy^IllqM^%h_ZMS- zISxE4P5PwsQ^P^~1$&s-I2c^xRBHsDQFD4~IC7-4rLa{!vTGANnLuD#;NKZP{8ihn z&YvOC@7Ivr-yP~QUq8(lLWwX$49r!`*ZmB2ss8xa-Dv-oX{4(I*TrI_ri_-eE>KX* zgCe0s^;ev4klo?7T&mlM$AcvyVAbdWFbJm zmV9bS>a`hyW%h+;ihJN|rDY?~Yt@HPdhOzib^w8A5>Ex*N@^WzlwHW>i^Mo83xYs~ z$*D_|7B}#ybD$8`zk_aM{XYJ495j*yn!kHWq!}+jO;Nmd?IG1YY-hEzPf4}!kri~f z_;y;@C4;Dl*28PAw?V$uUlxJE=Pu^_Rw91tX1Hk*_HeaYX$s?ZJ&>{$2GVH z(21JX%LzdDZSzxaG7@+x2Lv=A0;T&odBC}0Co1Pmox_g4dT*Y14Mc+G0d@ogVq8YW z5ID0#7ssW8uwBw_IaWc}8O2_s8HnGhb07bum8wqP`3BwC@xOI?d?#``Bj`%Vty4nt z|Cdv@i94X%qu9+DK%`b_Ky=%|e1oVMfsm%nLnaK*#lQ(qhcVY3y;g9B$3^CG3R83h z4e~*7z6)Jh0`ytB8R{ z*=8U<(Q}HOS>$Bz9gjWZDG;&~Gw;U%2S^!-%&k}X z^m$;lw9vtYXjZYVi94sSF+Uq%W4I5`HcMcmK?q&TvOJ&vaJ>@GY+JZ~FPhjGbLr$_ zYRLXu5%N>TCi$t&*NFln9JU54GF+3hBEy;^RolBFq>u4BnsUfYfN$LZ(z#`ABrW*a zb>bEfd;juQOO0_qOf!}6p9no`3cJ!L;oB;loG(6rbHv0jd3DYc03MJ1-rimM$a7-2 ztdMeHpBgkkr=G={i*8H$;(s1@wot%wSx>?6JUjGhI~E+&Qe{K&0dCCY55VLAnLp2X+niX= zm%ZS!kQ}?QijhU07+B!6%qh_s+Pn0j=>J=|jIYKk6Q0ADps5;9#^10F^FFT_mH z-~Vx%Y@6Qbg3VemYTB7?CKGy|vcF)wdhVZ7iZ6FZnNMaV9`zz$IWMI8eX4=7%XSf~ zkSB$7nRAz?XWk^Ix$C0!$KR+ywzSSoS0G9>Pn_9--KY%*46CwVgw*cQRw%+Ts-lm@ zn#PXIsESTIG4{+V(oROm3^e?Cy~i?ZV(ql*r3?H=!2xsJq6FqbngBoD6Fh7YM9gH( z4Ezdia&X)zw)P`fnQ^I_bN^}g?QncZa~A~q;|OP?{`J*g*_KgtcA(DiM^Ysh$0b@J z8Rb~?nL{Jhr--4vpms;bmyA2Ew_&mcI>Pbnw<@iI#6MJLKL<0e*s&DYAtn0v_a7gV z9>sfXpHiZ(vShCF#|{(EV3LrAGTAK!fTaFfLH)@hhRtaY! z?S=>lR7C@@q$%t)DdM14KkllaKHHr);dOG*2q*j9e^BPQ<}w5a~}YlG@1a+bowR+7w@TJWgYT0k5!W`S2i@z(P>v zc2%ZJJ<%q%PJ{*rzdXPxV1QFdNRZ}#-FoAT0$8t5iGx!H{IHAb(!x+*)h4}AbWYXLR}{65FPklokE5B{)O(;LP~a9{l(%Jk|X)RZW4PN^N;KD$}* zL;jOydR>WEPS7peV0#sf4lWRTn=mIm$Lu#N6EBCicvgU$J4b*3{ zQ?a?GixzNx?MC2H7(e-YejV~I(kjql(T%xwLLqGk+#YItGq-;RaV9~}z4Ygo-HZ40 zGe-&@JmaQ2bLqv4J0COKpIZdqxEPuS?$3!t+7@S3B1h&33lNgIYQa zWmHWs+CyhyT&kB0>$tXqRXQ$p3w1ZNK~CHvqDh;WI<%as_E?IlSZGD%hKO4PZC$Ep zkVue;pjwr<#351%e<#(~c{xw#rB8e9z4qP@zwi5f`}?i+?XNh@bGABXq_(X;&^Uy8 z3%Z4p4bxb|ju#jXiZVV=>+w(}^X5mp3nQJ|0M<7@!zZ}nc@TfC->q3Q#Gpy8F?Q%h z0%i3uBnQ9Y=!oTQww6Aa(or_eDtW}vSM@E(|G9EUk%HD}`}l!qymtC5=&sE0$5m|I}R+5=v`9?Z`6HO#HoL|5*JQjlVph&V2;Y7^+yi_-ki=1c_8Gc*(lDf_OH*4wBZ$_Iz!f6lb&6`=WD1QeB@wxiP_NxZ z;Bq#~oAg>}Xp||ev}!7rJbOZcaobas&vm$|X=7GJ5MR*2Z!jGQa7OAXq%a%jnHk7S zLQC^RI%nC6z&~!?-f}qOz5nipMz>@8FO=HH9WhJ&_tXSth7EXc4x%oPxJbRV(TgB2 z(pDHM%5Gb4~G5tdliG3n>rh6!Q4)D*U0<;^Ag1r9jYxIx@2Z z6vYRwSGkwv>(TofUL@2vRzdmVss0$rh^4T?g#(zhxR{EuH{L3di`XhTpv9@T4idU|q|tJycMS6Vs69K7A2_z+KovM5--rfE+)>e_l~e>9#v~V&L@0K^ znAI62qq5zsX2UmIC#(a`eR%C4`-qL50Kav|(*mL4NLR?e&I`yn25@d!?KBrHq6330 z=cm9mV=*|uuX~RzTm+cixs=t->9|>4MUZGa34#7cfTEkx$`z7yCk2u1?+TQrpKa1> z%(@U9_14m#!zz_XW&nYHH_rv!xwdV&!M4DydaGL_j+}dj>A~DY?X8AqiY}K2wb1da z7=K)sJR8N;u~|u#L&L1Y5PMdQTSt%9;1|Wo0zB~SE=g`xsNH6utDwH*pJrWE17!_) z^s2rKEZ;0U;cFh08It}G`dA&87V&5 z(nXebo#h{^a|^g_Mx_=QV>bJSlUvT1s*W|qNv2NXJ@GmOhL(eZf@MGTZ; zCtx9$8n7biDut*c!KFx2&MpI6yyU{jFr4^?;!h4zbc{}yCgU>gy?R zu|oz$1A1}WaelX__GY<-mvQTqrApSxUD^8KD}7HpR)Go(%|Y#C@)$Axs!A<@D?b=2 z^QtvAFwZp^uVsVzp)4>dTu|OAD`#Y%d(I4YRe3?h^X%8Jn^!A8oxmcyw5iUga66wC zYbXdm`Py75vKW2H(CEI)Gr@L~d*9ZzrFs}|Lj{yY$hoKfv_0rioX71cN&+5fc3Cup z>#LZ?@_rxRd?@`RuS!0T6BClXV&7bIBMq*HGN4x`Vo_$LTsm1gd}q|AwyIty{*=P` z7lW%wJD)Je)fq^aQ}8SB;5)-s9!#*vtL;dV-S#jCIc>b_YEi2K>R~UG)zs9SUNcXL zoObhtrzWypi{9I;!G}VP=DS4SlY*wfzTQ6@Im>Sy!&+cFcgW183LTi``-J)N7C6R< zwt}HqdXC{dVH`DD4z0aegN3q386ZL`G}F$WzGe5V{h z_sqV`K)SwPl@b1HfrO)Sqq1xy)8tEUDbTvxzFeAjLZqjsFTnRDRGH`3wY>epieCNw zMv4E+W#0}e#TV$}UnBhuoNwSLz2h6L{xS~zpV{%ow#*AS#A+WX{!KSwKkj+D`?yhl HjlT0=vI_+s literal 0 HcmV?d00001 diff --git a/www/assets/images/overriding-context.svg b/www/assets/images/overriding-context.svg new file mode 100644 index 000000000..d253d5232 --- /dev/null +++ b/www/assets/images/overriding-context.svg @@ -0,0 +1,410 @@ + + + + + + + yield* MyContext.set('A')yield* MyContext.set('B');yield* MyContext;BByield* MyContext;yield* MyContextBAyield* MyContext.set('C');yield* MyContext;CCA + diff --git a/www/assets/prism-atom-one-dark.css b/www/assets/prism-atom-one-dark.css new file mode 100644 index 000000000..653e01920 --- /dev/null +++ b/www/assets/prism-atom-one-dark.css @@ -0,0 +1,543 @@ +/** + * Added to make line numbering and higlight selection work: + * https://github.com/timlrx/rehype-prism-plus#styling + */ +pre { + overflow-x: auto; +} + +/** + * Inspired by gatsby remark prism - https://www.gatsbyjs.com/plugins/gatsby-remark-prismjs/ + * 1. Make the element just wide enough to fit its content. + * 2. Always fill the visible space in .code-highlight. + */ +.code-highlight { + float: left; /* 1 */ + min-width: 100%; /* 2 */ +} + +.code-line { + display: block; + padding-left: 16px; + padding-right: 16px; + margin-left: -16px; + margin-right: -16px; + border-left: 4px solid + rgba( + 0, + 0, + 0, + 0 + ); /* Set placeholder for highlight accent border color to transparent */ +} + +.code-line.inserted { + background-color: rgba(16, 185, 129, 0.2); /* Set inserted line (+) color */ +} + +.code-line.deleted { + background-color: rgba(239, 68, 68, 0.2); /* Set deleted line (-) color */ +} + +.highlight-line { + margin-left: -16px; + margin-right: -16px; + background-color: rgba(55, 65, 81, 0.5); /* Set highlight bg color */ + border-left: 4px solid + rgb(59, 130, 246); /* Set highlight accent border color */ +} + +.line-number::before { + display: inline-block; + width: 1rem; + text-align: right; + margin-right: 16px; + margin-left: -8px; + color: var(--prism-syntax-gutter); /* Line number color */ + content: attr(line); +} + +/** + * One Dark theme for prism.js + * Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax + */ + +@media (prefers-color-scheme: light) { + :root { + /* One Light colours */ + --prism-mono-1: hsl(230, 8%, 24%); + --prism-mono-2: hsl(230, 6%, 44%); + --prism-mono-3: hsl(230, 4%, 64%); + --prism-hue-1: hsl(198, 99%, 37%); + --prism-hue-2: hsl(221, 87%, 60%); + --prism-hue-3: hsl(301, 63%, 32%); + --prism-hue-4: hsl(119, 34%, 47%); + --prism-hue-5: hsl(5, 74%, 59%); + --prism-hue-5-2: hsl(344, 84%, 43%); + --prism-hue-6: hsl(35, 99%, 36%); + --prism-hue-6-2: hsl(35, 99%, 40%); + --prism-syntax-fg: hsl(230, 8%, 24%); + --prism-syntax-bg: hsl(230, 1%, 98%); + --prism-syntax-gutter: hsl(230, 1%, 62%); + --prism-syntax-guide: hsla(230, 8%, 24%, 0.2); + --prism-syntax-accent: hsl(230, 100%, 66%); + --prism-syntax-selection-color: hsl(230, 1%, 90%); + --prism-syntax-gutter-background-color-selected: hsl(230, 1%, 92%); + --prism-syntax-cursor-line: hsla(230, 8%, 24%, 0.04); + } + + /* Global code block overrides for light mode */ + pre.grid, + code.code-highlight { + color: var(--prism-syntax-fg) !important; + background: var(--prism-syntax-bg) !important; + } +} + +@media (prefers-color-scheme: dark) { + :root { + /* One Dark colours (accurate as of commit 8ae45ca on 6 Sep 2018) */ + --prism-mono-1: hsl(220, 14%, 71%); + --prism-mono-2: hsl(220, 9%, 55%); + --prism-mono-3: hsl(220, 10%, 40%); + --prism-hue-1: hsl(187, 47%, 55%); + --prism-hue-2: hsl(207, 82%, 66%); + --prism-hue-3: hsl(286, 60%, 67%); + --prism-hue-4: hsl(95, 38%, 62%); + --prism-hue-5: hsl(355, 65%, 65%); + --prism-hue-5-2: hsl(5, 48%, 51%); + --prism-hue-6: hsl(29, 54%, 61%); + --prism-hue-6-2: hsl(39, 67%, 69%); + --prism-syntax-fg: hsl(220, 14%, 71%); + --prism-syntax-bg: hsl(220, 13%, 18%); + --prism-syntax-gutter: hsl(220, 14%, 45%); + --prism-syntax-guide: hsla(220, 14%, 71%, 0.15); + --prism-syntax-accent: hsl(220, 100%, 66%); + --prism-syntax-selection-color: hsl(220, 13%, 28%); + --prism-syntax-gutter-background-color-selected: hsl(220, 13%, 26%); + --prism-syntax-cursor-line: hsla(220, 55%, 36%, 0.04); + } + + /* Global code block overrides for dark mode */ + code, + code::before, + code::after { + color: var(--prism-syntax-fg) !important; + } +} + +code[class*="language-"], +pre[class*="language-"] { + background: var(--prism-syntax-bg); + color: var(--prism-syntax-fg); + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: + "Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Selection */ +code[class*="language-"]::-moz-selection, +code[class*="language-"] *::-moz-selection, +pre[class*="language-"] *::-moz-selection { + background: var(--prism-syntax-selection-color); + color: inherit; + text-shadow: none; +} + +code[class*="language-"]::selection, +code[class*="language-"] *::selection, +pre[class*="language-"] *::selection { + background: var(--prism-syntax-selection-color); + color: inherit; + text-shadow: none; +} + +/* Code blocks */ +pre[class*="language-"] { + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.2em 0.3em; + border-radius: 0.3em; + white-space: normal; +} + +/* Print */ +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} + +.token.comment, +.token.prolog, +.token.cdata { + color: var(--prism-mono-3); +} + +.token.doctype, +.token.punctuation, +.token.entity { + color: var(--prism-mono-1); +} + +.token.attr-name, +.token.class-name, +.token.boolean, +.token.constant, +.token.number, +.token.atrule { + color: var(--prism-hue-6); +} + +.token.keyword { + color: var(--prism-hue-3); +} + +.token.property, +.token.tag, +.token.symbol, +.token.deleted, +.token.important { + color: var(--prism-hue-5); +} + +.token.selector, +.token.string, +.token.char, +.token.builtin, +.token.inserted, +.token.regex, +.token.attr-value, +.token.attr-value > .token.punctuation { + color: var(--prism-hue-4); +} + +.token.variable, +.token.operator, +.token.function { + color: var(--prism-hue-2); +} + +.token.url { + color: var(--prism-hue-1); +} + +/* HTML overrides */ +.token.attr-value > .token.punctuation.attr-equals, +.token.special-attr > .token.attr-value > .token.value.css { + color: var(--prism-mono-1); +} + +/* CSS overrides */ +.language-css .token.selector { + color: var(--prism-hue-5); +} + +.language-css .token.property { + color: var(--prism-mono-1); +} + +.language-css .token.function, +.language-css .token.url > .token.function { + color: var(--prism-hue-1); +} + +.language-css .token.url > .token.string.url { + color: var(--prism-hue-4); +} + +.language-css .token.important, +.language-css .token.atrule .token.rule { + color: var(--prism-hue-3); +} + +/* JS overrides */ +.language-javascript .token.operator { + color: var(--prism-hue-3); +} + +.language-javascript + .token.template-string + > .token.interpolation + > .token.interpolation-punctuation.punctuation { + color: var(--prism-hue-5-2); +} + +/* JSON overrides */ +.language-json .token.operator { + color: var(--prism-mono-1); +} + +.language-json .token.null.keyword { + color: var(--prism-hue-6); +} + +/* MD overrides */ +.language-markdown .token.url, +.language-markdown .token.url > .token.operator, +.language-markdown .token.url-reference.url > .token.string { + color: var(--prism-mono-1); +} + +.language-markdown .token.url > .token.content { + color: var(--prism-hue-2); +} + +.language-markdown .token.url > .token.url, +.language-markdown .token.url-reference.url { + color: var(--prism-hue-1); +} + +.language-markdown .token.blockquote.punctuation, +.language-markdown .token.hr.punctuation { + color: var(--prism-mono-3); + font-style: italic; +} + +.language-markdown .token.code-snippet { + color: var(--prism-hue-4); +} + +.language-markdown .token.bold .token.content { + color: var(--prism-hue-6); +} + +.language-markdown .token.italic .token.content { + color: var(--prism-hue-3); +} + +.language-markdown .token.strike .token.content, +.language-markdown .token.strike .token.punctuation, +.language-markdown .token.list.punctuation, +.language-markdown .token.title.important > .token.punctuation { + color: var(--prism-hue-5); +} + +/* General */ +.token.bold { + font-weight: bold; +} + +.token.comment, +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + +.token.namespace { + opacity: 0.8; +} + +/* Plugin overrides */ +/* Selectors should have higher specificity than those in the plugins' default stylesheets */ + +/* Show Invisibles plugin overrides */ +.token.token.tab:not(:empty):before, +.token.token.cr:before, +.token.token.lf:before, +.token.token.space:before { + color: var(--prism-syntax-guide); + text-shadow: none; +} + +/* Toolbar plugin overrides */ +/* Space out all buttons and move them away from the right edge of the code block */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item { + margin-right: 0.4em; +} + +/* Styling the buttons */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span { + background: var(--prism-syntax-gutter-background-color-selected); + color: var(--prism-mono-2); + padding: 0.1em 0.4em; + border-radius: 0.3em; +} + +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus { + background: var(--prism-syntax-selection-color); + color: var(--prism-mono-1); +} + +/* Line Highlight plugin overrides */ +/* The highlighted line itself */ +.line-highlight.line-highlight { + background: var(--prism-syntax-cursor-line); +} + +/* Default line numbers in Line Highlight plugin */ +.line-highlight.line-highlight:before, +.line-highlight.line-highlight[data-end]:after { + background: var(--prism-syntax-gutter-background-color-selected); + color: var(--prism-mono-1); + padding: 0.1em 0.6em; + border-radius: 0.3em; + box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */ +} + +/* Hovering over a linkable line number (in the gutter area) */ +/* Requires Line Numbers plugin as well */ +pre[id].linkable-line-numbers.linkable-line-numbers + span.line-numbers-rows + > span:hover:before { + background-color: var(--prism-syntax-cursor-line); +} + +/* Line Numbers and Command Line plugins overrides */ +/* Line separating gutter from coding area */ +.line-numbers.line-numbers .line-numbers-rows, +.command-line .command-line-prompt { + border-right-color: var(--prism-syntax-guide); +} + +/* Stuff in the gutter */ +.line-numbers .line-numbers-rows > span:before, +.command-line .command-line-prompt > span:before { + color: var(--prism-syntax-gutter); +} + +/* Match Braces plugin overrides */ +/* Note: Outline colour is inherited from the braces */ +.rainbow-braces .token.token.punctuation.brace-level-1, +.rainbow-braces .token.token.punctuation.brace-level-5, +.rainbow-braces .token.token.punctuation.brace-level-9 { + color: var(--prism-hue-5); +} + +.rainbow-braces .token.token.punctuation.brace-level-2, +.rainbow-braces .token.token.punctuation.brace-level-6, +.rainbow-braces .token.token.punctuation.brace-level-10 { + color: var(--prism-hue-4); +} + +.rainbow-braces .token.token.punctuation.brace-level-3, +.rainbow-braces .token.token.punctuation.brace-level-7, +.rainbow-braces .token.token.punctuation.brace-level-11 { + color: var(--prism-hue-2); +} + +.rainbow-braces .token.token.punctuation.brace-level-4, +.rainbow-braces .token.token.punctuation.brace-level-8, +.rainbow-braces .token.token.punctuation.brace-level-12 { + color: var(--prism-hue-3); +} + +/* Diff Highlight plugin overrides */ +/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */ +pre.diff-highlight > code .token.token.deleted:not(.prefix), +pre > code.diff-highlight .token.token.deleted:not(.prefix) { + background-color: hsla(353, 100%, 66%, 0.15); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix), +pre > code.diff-highlight .token.token.inserted:not(.prefix) { + background-color: hsla(137, 100%, 55%, 0.15); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +/* Previewers plugin overrides */ +/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */ +/* Border around popup */ +.prism-previewer.prism-previewer:before, +.prism-previewer-gradient.prism-previewer-gradient div { + border-color: hsl(224, 13%, 17%); +} + +/* Angle and time should remain as circles and are hence not included */ +.prism-previewer-color.prism-previewer-color:before, +.prism-previewer-gradient.prism-previewer-gradient div, +.prism-previewer-easing.prism-previewer-easing:before { + border-radius: 0.3em; +} + +/* Triangles pointing to the code */ +.prism-previewer.prism-previewer:after { + border-top-color: hsl(224, 13%, 17%); +} + +.prism-previewer-flipped.prism-previewer-flipped.after { + border-bottom-color: hsl(224, 13%, 17%); +} + +/* Background colour within the popup */ +.prism-previewer-angle.prism-previewer-angle:before, +.prism-previewer-time.prism-previewer-time:before, +.prism-previewer-easing.prism-previewer-easing { + background: hsl(219, 13%, 22%); +} + +/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */ +/* For time, this is the alternate colour */ +.prism-previewer-angle.prism-previewer-angle circle, +.prism-previewer-time.prism-previewer-time circle { + stroke: var(--prism-mono-1); + stroke-opacity: 1; +} + +/* Stroke colours of the handle, direction point, and vector itself */ +.prism-previewer-easing.prism-previewer-easing circle, +.prism-previewer-easing.prism-previewer-easing path, +.prism-previewer-easing.prism-previewer-easing line { + stroke: var(--prism-mono-1); +} + +/* Fill colour of the handle */ +.prism-previewer-easing.prism-previewer-easing circle { + fill: transparent; +} diff --git a/www/assets/search.js b/www/assets/search.js new file mode 100644 index 000000000..837e84938 --- /dev/null +++ b/www/assets/search.js @@ -0,0 +1,98 @@ +import { + all, + createChannel, + each, + main, + on, + resource, + sleep, + spawn, + // deno-lint-ignore no-import-prefix +} from "https://esm.sh/effection@4.0.0-beta.3"; + +await main(function* () { + const input = document.getElementById("search"); + if (!input) { + console.log(`Search could not be setup because input was not found.`); + return; + } + + const label = input.closest("label"); + if (!label) { + console.log( + `Search could not be setup because label element was not found.`, + ); + return; + } + + const button = input.nextElementSibling; + if (!button) { + console.log( + `Search could not be setup because button element was not found.`, + ); + return; + } + + const events = yield* join([ + on(input, "focus"), + on(button, "focus"), + on(input, "blur"), + on(button, "blur"), + ]); + + /** @type {Task} */ + let lastBlur; + yield* spawn(function* () { + for (const event of yield* each(events)) { + if (event.type === "blur") { + lastBlur = yield* spawn(function* () { + yield* sleep(15); + input.value = ""; + input.setAttribute("placeholder", "⌘K"); + input.classList.remove("focused"); + }); + } else { + if (lastBlur) { + yield* lastBlur.halt(); + } + input.removeAttribute("placeholder"); + input.classList.add("focused"); + } + yield* each.next(); + } + }); + + for (const event of yield* each(on(document, "keydown"))) { + if (event.metaKey && event.key === "k") { + event.preventDefault(); + input.focus(); + } + if (event.key === "Escape") { + input.blur(); + } + yield* each.next(); + } +}); + +/** + * Combine multiple streams into a single stream + * @template {T} + * @param {Stream[]} streams + * @returns {Operation>} + */ +function join(streams) { + return resource(function* (provide) { + const channel = createChannel(); + + yield* spawn(function* () { + yield* all(streams.map(function* (stream) { + for (const event of yield* each(stream)) { + yield* channel.send(event); + yield* each.next(); + } + })); + }); + + yield* provide(channel); + }); +} diff --git a/www/components/alert.tsx b/www/components/alert.tsx new file mode 100644 index 000000000..ef1ad9164 --- /dev/null +++ b/www/components/alert.tsx @@ -0,0 +1,35 @@ +// @ts-nocheck Property 'role' does not exist on type 'HTMLElement'.deno-ts(2322) +import { JSXChild } from "revolution/jsx-runtime"; + +const ALERT_LEVELS = { + warning: + "bg-orange-100 border-orange-500 text-orange-700 dark:bg-orange-900 dark:border-orange-300 dark:text-orange-200", + error: + "bg-red-100 border-red-400 text-red-700 dark:bg-red-900 dark:border-red-300 dark:text-red-200", + info: + "bg-blue-100 border-blue-500 text-blue-700 dark:bg-blue-900 dark:border-blue-300 dark:text-blue-200", +} as const; + +export function Alert({ + title, + children, + class: className, + level, +}: { + title?: string; + level: "info" | "warning" | "error"; + children: JSXChild; + class?: string; +}) { + return ( +
p]:my-1`} + role="alert" + > + {title ?

{title}

: <>} + {children} +
+ ); +} diff --git a/www/components/api/api-page.tsx b/www/components/api/api-page.tsx new file mode 100644 index 000000000..a8bfc9495 --- /dev/null +++ b/www/components/api/api-page.tsx @@ -0,0 +1,208 @@ +import type { JSXElement } from "revolution"; +import { DocPage } from "../../hooks/use-deno-doc.tsx"; +import { ResolveLinkFunction, useMarkdown } from "../../hooks/use-markdown.tsx"; +import { Package } from "../../lib/package.ts"; +import { major } from "../../lib/semver.ts"; +import { createSibling } from "../../routes/links-resolvers.ts"; +import { IconExternal } from "../icons/external.tsx"; +import { SourceCodeIcon } from "../icons/source-code.tsx"; +import { GithubPill } from "../package/source-link.tsx"; +import { Icon } from "../type/icon.tsx"; +import { Type } from "../type/jsx.tsx"; +import { Keyword } from "../type/tokens.tsx"; + +export function* ApiPage({ + pages, + current, + pkg, + externalLinkResolver, + banner, +}: { + current: string; + pages: DocPage[]; + pkg: Package; + banner?: JSXElement; + externalLinkResolver: ResolveLinkFunction; +}) { + const page = pages.find((node) => node.name === current); + + if (!page) throw new Error(`Could not find a doc page for ${current}`); + + const linkResolver: ResolveLinkFunction = function* resolve( + symbol, + connector, + method, + ) { + const target = pages && + pages.find((page) => page.name === symbol && page.kind !== "import"); + + if (target) { + return `[${ + [symbol, connector, method].join( + "", + ) + }](${yield* externalLinkResolver(symbol, connector, method)})`; + } else { + return symbol; + } + }; + + return ( + <> + {yield* ApiReference({ + pages, + current, + pkg, + content: ( + <> + <>{banner} + {yield* SymbolHeader({ pkg, page })} + {yield* ApiBody({ page, linkResolver })} + + ), + linkResolver: createSibling, + })} + + ); +} + +export function* ApiBody({ + page, + linkResolver, +}: { + page: DocPage; + linkResolver: ResolveLinkFunction; +}) { + const elements: JSXElement[] = []; + + for (const [i, section] of Object.entries(page.sections)) { + if (section.markdown) { + elements.push( +
+
+

+ {yield* Type({ node: section.node })} +

+ + + +
+
+ {yield* useMarkdown(section.markdown, { + linkResolver, + slugPrefix: section.id, + })} +
+
, + ); + } + } + + return <>{elements}; +} + +export function* ApiReference({ + pkg, + content, + current, + pages, + linkResolver, +}: { + pkg: Package; + content: JSXElement; + current: string; + pages: DocPage[]; + linkResolver: ResolveLinkFunction; +}) { + return ( +
+ +
+ {content} +
+
+ ); +} + +export function* SymbolHeader({ page, pkg }: { page: DocPage; pkg: Package }) { + return ( +
+

+ + {page.kind === "typeAlias" ? "type alias " : page.kind} + {" "} + {page.name} +

+ {yield* GithubPill({ + url: pkg.ref.url, + text: pkg.ref.nameWithOwner, + // url: pkg.source.toString(), + // text: pkg.ref.repository.nameWithOwner, + })} +
+ ); +} + +function* Menu({ + pages, + current, + linkResolver, +}: { + current: string; + pages: DocPage[]; + linkResolver: ResolveLinkFunction; +}) { + const elements = []; + for (const page of pages.sort((a, b) => a.name.localeCompare(b.name))) { + elements.push( +
  • + {current === page.name + ? ( + + + {page.name} + + ) + : ( + + + {page.name} + + )} +
  • , + ); + } + return {elements}; +} diff --git a/www/components/footer.tsx b/www/components/footer.tsx new file mode 100644 index 000000000..b5a89cc88 --- /dev/null +++ b/www/components/footer.tsx @@ -0,0 +1,57 @@ +import { IconExternal } from "./icons/external.tsx"; + +export function Footer(): JSX.Element { + return ( + + ); +} diff --git a/www/components/header.tsx b/www/components/header.tsx new file mode 100644 index 000000000..d4c3a2343 --- /dev/null +++ b/www/components/header.tsx @@ -0,0 +1,98 @@ +import { useWorkspace } from "../lib/workspace.ts"; +import { getStarCount } from "../lib/octokit.ts"; +import { IconDiscord } from "./icons/discord.tsx"; +import { IconGithub } from "./icons/github.tsx"; +import { StarIcon } from "./icons/star.tsx"; +import { Navburger } from "./navburger.tsx"; +import { SearchInput } from "./search-input.tsx"; + +export interface HeaderProps { + hasLeftSidebar?: boolean; +} + +export function* Header(props?: HeaderProps) { + let x = yield* useWorkspace("thefrontside/effectionx"); + + return ( +
    +
    +
    + + Effection Logo + +
    + +
    +
    + ); +} diff --git a/www/components/icons/cartouche.tsx b/www/components/icons/cartouche.tsx new file mode 100644 index 000000000..6123cc270 --- /dev/null +++ b/www/components/icons/cartouche.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconCartouche = () => ( + + + + + + + + + +); diff --git a/www/components/icons/discord.tsx b/www/components/icons/discord.tsx new file mode 100644 index 000000000..9960366d8 --- /dev/null +++ b/www/components/icons/discord.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconDiscord = () => ( + +); diff --git a/www/components/icons/external.tsx b/www/components/icons/external.tsx new file mode 100644 index 000000000..0c7e828ec --- /dev/null +++ b/www/components/icons/external.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +import { JSXComponentProps } from "revolution/jsx-runtime"; + +export const IconExternal = (props: JSXComponentProps = {}) => ( + +); diff --git a/www/components/icons/github.tsx b/www/components/icons/github.tsx new file mode 100644 index 000000000..163c88430 --- /dev/null +++ b/www/components/icons/github.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconGithub = () => ( + +); diff --git a/www/components/icons/info.tsx b/www/components/icons/info.tsx new file mode 100644 index 000000000..cd7a45e5b --- /dev/null +++ b/www/components/icons/info.tsx @@ -0,0 +1,15 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) + +export function InfoIcon() { + return ( + + ); +} diff --git a/www/components/icons/search.tsx b/www/components/icons/search.tsx new file mode 100644 index 000000000..ef94a2661 --- /dev/null +++ b/www/components/icons/search.tsx @@ -0,0 +1,19 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function SearchIcon(props) { + return ( + + + + + + ); +} diff --git a/www/components/icons/source-code.tsx b/www/components/icons/source-code.tsx new file mode 100644 index 000000000..70e08ba53 --- /dev/null +++ b/www/components/icons/source-code.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) + +export function SourceCodeIcon(props) { + return ( + + + + + + + ); +} diff --git a/www/components/icons/star.tsx b/www/components/icons/star.tsx new file mode 100644 index 000000000..4dc7ae494 --- /dev/null +++ b/www/components/icons/star.tsx @@ -0,0 +1,17 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function StarIcon(props) { + return ( + + ); +} diff --git a/www/components/icons/typescript.tsx b/www/components/icons/typescript.tsx new file mode 100644 index 000000000..930ca7a31 --- /dev/null +++ b/www/components/icons/typescript.tsx @@ -0,0 +1,21 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function IconTSLogo() { + return ( + + + + + + ); +} diff --git a/www/components/navburger.tsx b/www/components/navburger.tsx new file mode 100644 index 000000000..f61168583 --- /dev/null +++ b/www/components/navburger.tsx @@ -0,0 +1,13 @@ +//@ts-nocheck hastx does not currently typecheck correctly +export function Navburger() { + return ( + + Mobile menu + + + ); +} diff --git a/www/components/package/cicle-score.tsx b/www/components/package/cicle-score.tsx new file mode 100644 index 000000000..9757cf901 --- /dev/null +++ b/www/components/package/cicle-score.tsx @@ -0,0 +1,34 @@ +import { PackageDetailsResult } from "../../resources/jsr-client.ts"; + +export function CircleScore({ details }: { details: PackageDetailsResult }) { + return ( + <> +

    + JSR Logo + Score +

    +
    + + {details.score}% + +
    + + ); +} + +/** @src https://github.com/jsr-io/jsr/blob/34603e996f56eb38e811619f8aebc6e5c4ad9fa7/frontend/utils/score_ring_color.ts */ +export function getScoreBgColorClass(score: number): string { + if (score >= 90) { + return "bg-green-500"; + } else if (score >= 60) { + return "bg-yellow-500"; + } + return "bg-red-500"; +} diff --git a/www/components/package/cross.tsx b/www/components/package/cross.tsx new file mode 100644 index 000000000..2477f1287 --- /dev/null +++ b/www/components/package/cross.tsx @@ -0,0 +1,15 @@ +; diff --git a/www/components/package/exports.tsx b/www/components/package/exports.tsx new file mode 100644 index 000000000..54903e03d --- /dev/null +++ b/www/components/package/exports.tsx @@ -0,0 +1,103 @@ +import { join } from "@std/path"; + +import { Keyword, Punctuation } from "../type/tokens.tsx"; +import { DocPage, DocsPages } from "../../hooks/use-deno-doc.tsx"; +import { Operation } from "effection"; +import { JSXChild, JSXElement } from "revolution/jsx-runtime"; +import { ResolveLinkFunction } from "../../hooks/use-markdown.tsx"; + +interface PackageExportsParams { + packageName: string; + docs: DocsPages; + linkResolver: ResolveLinkFunction; +} + +export function* PackageExports({ + packageName, + docs, + linkResolver, +}: PackageExportsParams) { + const elements: JSXElement[] = []; + + for (const [exportName, docPages] of Object.entries(docs)) { + if (docPages.filter((page) => page.kind !== "import").length > 0) { + elements.push( + yield* PackageExport({ + linkResolver, + packageName, + exportName, + docPages, + }), + ); + } + } + + return elements; +} + +interface PackageExportOptions { + packageName: string; + exportName: string; + docPages: Array; + linkResolver: ResolveLinkFunction; +} + +function* PackageExport({ + packageName, + exportName, + docPages, + linkResolver, +}: PackageExportOptions): Operation { + const exports: JSXChild[] = []; + + for ( + const page of docPages + .flatMap((page) => (page.kind === "import" ? [] : [page])) + .sort((a, b) => a.name.localeCompare(b.name)) + ) { + exports.push( + ...[ + + {["enum", "typeAlias", "namespace", "interface"].includes( + page.kind, + ) + ? {"type "} + : ( + "" + )} + {page.name} + , + ", ", + ], + ); + } + + return ( +
    +      
    +        import
    +        {" { "}
    +        
      + {chunk(exports.slice(0, -1)).map((chunk) => ( +
    • {chunk}
    • + ))} +
    + {"} "} + {"from "} + "{join(packageName, exportName)}" +
    +
    + ); +} + +function chunk(array: T[], chunkSize = 2): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + const chunk = array.slice(i, i + chunkSize); + chunks.push(chunk); + } + return chunks; +} diff --git a/www/components/package/header.tsx b/www/components/package/header.tsx new file mode 100644 index 000000000..d846d8a96 --- /dev/null +++ b/www/components/package/header.tsx @@ -0,0 +1,35 @@ +import { Package } from "../../lib/package.ts"; +import { GithubPill } from "./source-link.tsx"; + +export function* PackageHeader(pkg: Package) { + return ( +
    +
    + + + @{pkg.scopeName} + / + {pkg.name.split("/")[1]} + + v{pkg.version ? pkg.version : ""} + + {yield* GithubPill({ + class: "mt-2 xl:mt-0", + url: pkg.ref.url, + text: pkg.ref.nameWithOwner, + })} +
    + +
    + ); +} diff --git a/www/components/package/icons.tsx b/www/components/package/icons.tsx new file mode 100644 index 000000000..d04b8d363 --- /dev/null +++ b/www/components/package/icons.tsx @@ -0,0 +1,715 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'. + +export interface IconProps { + class?: string; + height?: string; + width?: string; + style?: string; +} + +export function Check() { + return ( + + ); +} + +export function Cross() { + return ( + + ); +} + +export function BrowserIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function BunIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + ); +} + +export function CloudflareWorkersIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function DenoIcon(props: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function NodeIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function NPMIcon(props: IconProps) { + return ( + + + + + ); +} + +export function JSRIcon(props: IconProps) { + return ( + + + + + + + + + ); +} diff --git a/www/components/package/source-link.tsx b/www/components/package/source-link.tsx new file mode 100644 index 000000000..7911a23f1 --- /dev/null +++ b/www/components/package/source-link.tsx @@ -0,0 +1,25 @@ +import { IconExternal } from "../../components/icons/external.tsx"; +import { IconGithub } from "../../components/icons/github.tsx"; + +export function* GithubPill({ + url, + text, + ...props +}: { + url: string; + text: string; + class?: string; +}) { + return ( + + + {text} + + + ); +} diff --git a/www/components/project-select.tsx b/www/components/project-select.tsx new file mode 100644 index 000000000..b65d0f1c3 --- /dev/null +++ b/www/components/project-select.tsx @@ -0,0 +1,91 @@ +import { JSXComponentProps } from "revolution/jsx-runtime"; + +export function ProjectSelect(props: JSXComponentProps) { + let uuid = self.crypto.randomUUID(); + + let toggleId = `toggle-${uuid}`; + let openerId = `opener-${uuid}`; + let closerId = `closer-${uuid}`; + + return ( +
    + + + + +
    + ); +} + +const projects = [ + { + title: "Interactors", + description: "Page Objects for components libraries", + url: "https://frontside.com/interactors", + version: "v1", + img: + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmEgewogICAgICAgIGZpbGw6ICNmZmY7CiAgICAgIH0KCiAgICAgIC5hLCAuYiwgLmMgewogICAgICAgIHN0cm9rZTogIzE0MzA1YzsKICAgICAgICBzdHJva2UtbWl0ZXJsaW1pdDogMTA7CiAgICAgICAgc3Ryb2tlLXdpZHRoOiAycHg7CiAgICAgIH0KCiAgICAgIC5iIHsKICAgICAgICBmaWxsOiAjMjZhYmU4OwogICAgICB9CgogICAgICAuYyB7CiAgICAgICAgZmlsbDogI2Y3NGQ3YjsKICAgICAgfQogICAgPC9zdHlsZT4KICA8L2RlZnM+CiAgPGc+CiAgICA8ZWxsaXBzZSBjbGFzcz0iYSIgY3g9IjI4LjQ1IiBjeT0iNi45MSIgcng9IjQiIHJ5PSIzLjg5Ii8+CiAgICA8cGF0aCBjbGFzcz0iYiIgZD0iTTI4LjQ1LDEwLjhhMy45NCwzLjk0LDAsMCwxLTQtMy44OSw0LDQsMCwwLDEsLjI1LTEuMzQsNi40NSw2LjQ1LDAsMCwwLTEuMzMtLjE0QTYuMyw2LjMsMCwwLDAsMTcsMTEuNjRhNi4zOCw2LjM4LDAsMCwwLDEyLjc1LDAsNi45MSw2LjkxLDAsMCwwLS4xLTFBNC4zLDQuMywwLDAsMSwyOC40NSwxMC44WiIvPgogICAgPHBhdGggY2xhc3M9ImMiIGQ9Ik0xNywxMS42NGE2LjI2LDYuMjYsMCwwLDEsLjEtMSwxMS4xMSwxMS4xMSwwLDAsMC00LjY5LTEsMTAuODIsMTAuODIsMCwwLDAtMTEsMTAuNjhBMTAuODIsMTAuODIsMCwwLDAsMTIuNDEsMzFhMTAuODMsMTAuODMsMCwwLDAsMTEtMTAuNjgsMTAuNjYsMTAuNjYsMCwwLDAtLjMxLTIuNDhBNi4yNyw2LjI3LDAsMCwxLDE3LDExLjY0WiIvPgogIDwvZz4KPC9zdmc+Cg==", + }, + { + title: "Auth0 Simulator", + description: "Enabling testing and local development", + url: "https://github.com/thefrontside/simulacrum/tree/v0/packages/auth0", + version: "v0", + img: + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmEgewogICAgICAgIGZpbGw6ICMxNDMxNWQ7CiAgICAgIH0KCiAgICAgIC5iIHsKICAgICAgICBmaWxsOiAjMjZhYmU4OwogICAgICB9CgogICAgICAuYyB7CiAgICAgICAgZmlsbDogI2Y3NGQ3YjsKICAgICAgICBmaWxsLXJ1bGU6IGV2ZW5vZGQ7CiAgICAgIH0KICAgIDwvc3R5bGU+CiAgPC9kZWZzPgogIDxnPgogICAgPHBhdGggY2xhc3M9ImEiIGQ9Ik0xNyw1LjE2bDEwLjMzLDUuOTVWMjIuODlMMTcsMjguODQsNi42NywyMi44OVYxMS4xMUwxNyw1LjE2TTE3LC4yOCwyLjQ1LDguNjZWMjUuMzRMMTcsMzMuNzJsMTQuNTUtOC4zOFY4LjY2TDE3LC4yOFoiLz4KICAgIDxwYXRoIGNsYXNzPSJiIiBkPSJNMTcsNy42OWw4LjEyLDQuNjd2OS4yOEwxNywyNi4zMSw4Ljg4LDIxLjY0VjEyLjM2TDE3LDcuNjltMC0zLjI1TDYuMDYsMTAuNzRWMjMuMjZMMTcsMjkuNTZsMTAuOTQtNi4zVjEwLjc0TDE3LDQuNDRaIi8+CiAgICA8cG9seWdvbiBjbGFzcz0iYyIgcG9pbnRzPSIxNyAxMy44OCAxNC4yOCAxNS40NCAxNC4yOCAxOC41NiAxNyAyMC4xMiAxOS43MiAxOC41NiAxOS43MiAxNS40NCAxNyAxMy44OCIvPgogIDwvZz4KPC9zdmc+Cg==", + }, +]; diff --git a/www/components/rehype.tsx b/www/components/rehype.tsx new file mode 100644 index 000000000..6b99e0810 --- /dev/null +++ b/www/components/rehype.tsx @@ -0,0 +1,23 @@ +import { type PluggableList, unified } from "unified"; + +export interface RehypeOptions { + children: JSX.Element; + plugins: PluggableList; +} + +export function Rehype(options: RehypeOptions): JSX.Element { + let { children, plugins } = options; + let pipeline = unified().use(plugins); + + let result = pipeline.runSync(children); + if ( + result.type === "text" || result.type === "element" || + result.type === "root" + ) { + return result as JSX.Element; + } else { + throw new Error( + `rehype plugin stack: {options.plugins} did not return a HAST Element`, + ); + } +} diff --git a/www/components/score-card.tsx b/www/components/score-card.tsx new file mode 100644 index 000000000..415c44a13 --- /dev/null +++ b/www/components/score-card.tsx @@ -0,0 +1,212 @@ +import { JSXElement } from "revolution/jsx-runtime"; +import { type PackageScoreResult } from "../resources/jsr-client.ts"; +import { + BrowserIcon, + BunIcon, + Check, + CloudflareWorkersIcon, + Cross, + DenoIcon, + IconProps, + JSRIcon, + NodeIcon, + NPMIcon, +} from "./package/icons.tsx"; +import { Package } from "../lib/package.ts"; + +export function* ScoreCard(pkg: Package) { + const [details, score] = yield* pkg.jsrPackageDetails(); + + const jsrScore = (details?.success && details.data && details.data.score) || + 0; + + return ( +
    +
    +
    + + +
    +
    +
    TypeScript
    + Yes +
    +
    + +
    +
    + + + + + +
    +
    +
    + +
    JSR Score
    +
    + {jsrScore}% +
    +
    +
    + {score?.success && score.data + ? + : <>} +
    + ); +} + +interface SupportedEnvironmentProps { + name: string; + enabled: boolean; + width: number; + height: number; + Icon: (props: IconProps) => JSXElement; +} + +function SupportedEnvironment(props: SupportedEnvironmentProps) { + return ( +
    + + {props.enabled ? <> : ( + + )} +
    + ); +} + +function ScoreDescription({ + score, + pkg, +}: { + score: PackageScoreResult; + pkg: Package; +}) { + const { + percentageDocumentedSymbols: _percentageDocumentedSymbols, + total: _total, + ...flags + } = score; + + const SCORE_MAP = { + hasReadme: "Has a readme or module doc", + hasReadmeExamples: "Has examples in the readme or module doc", + allEntrypointsDocs: "Has module docs in all entrypoints", + allFastCheck: ( + <> + No{" "} + + slow types + {" "} + are used + + ), + hasProvenance: "Has provenance", + hasDescription: ( + <> + Has a{" "} + + description + + + ), + atLeastOneRuntimeCompatible: "At least one runtime is marked as compatible", + multipleRuntimesCompatible: + "At least two runtimes are marked as compatible", + }; + + return ( +
    + + The JSR score is a measure of the overall quality of a package, expand + for more detail. + +
      + <> + {Object.entries(flags).map(([key, value]) => ( +
    • + {value ? : } + {SCORE_MAP[key as keyof typeof flags]} +
    • + ))} + +
    +
    + ); +} + +/** @src https://github.com/jsr-io/jsr/blob/34603e996f56eb38e811619f8aebc6e5c4ad9fa7/frontend/utils/score_ring_color.ts */ +export function getScoreTextColorClass(score: number): string { + if (score >= 90) { + return "text-green-600"; + } else if (score >= 60) { + return "text-yellow-700"; + } + return "text-red-500"; +} diff --git a/www/components/search-input.tsx b/www/components/search-input.tsx new file mode 100644 index 000000000..43724a05d --- /dev/null +++ b/www/components/search-input.tsx @@ -0,0 +1,24 @@ +import { SearchIcon } from "./icons/search.tsx"; + +export function SearchInput() { + return ( +
    + +
    + ); +} diff --git a/www/components/transform.tsx b/www/components/transform.tsx new file mode 100644 index 000000000..44478d05f --- /dev/null +++ b/www/components/transform.tsx @@ -0,0 +1,44 @@ +import type { JSXChild, JSXElement } from "revolution"; + +export interface Transformer { + (node: JSXElement): JSXElement; +} + +export interface TransformOptions { + fn: Transformer; + children: JSXChild | JSXChild[]; +} + +export function Transform(options: TransformOptions): JSX.Element { + let { children, fn } = options; + + if (Array.isArray(children)) { + return { + type: "root", + //@ts-expect-error dem hast types! + children: children.map((child) => transform(child, fn)), + }; + } else { + return transform(children, fn); + } +} + +export function transform(child: JSXChild, fn: Transformer): JSX.Element { + switch (typeof child) { + case "string": + case "number": + case "boolean": + return fn({ type: "text", value: String(child) }); + default: + switch (child.type) { + case "text": + case "element": + return fn(child); + default: { + let children = child.children as unknown as JSXElement[]; + //@ts-expect-error dem hast types! + return { type: "root", children: children.map(fn) }; + } + } + } +} diff --git a/www/components/type/icon.tsx b/www/components/type/icon.tsx new file mode 100644 index 000000000..9f8dd3ba6 --- /dev/null +++ b/www/components/type/icon.tsx @@ -0,0 +1,46 @@ +export function Icon(props: { kind: string; class?: string }) { + switch (props.kind) { + case "function": + return ( + + f + + ); + case "interface": + return ( + + I + + ); + case "typeAlias": + return ( + + T + + ); + case "variable": { + return ( + + v + + ); + } + } + return <>; +} diff --git a/www/components/type/jsx.tsx b/www/components/type/jsx.tsx new file mode 100644 index 000000000..2f5cff5a1 --- /dev/null +++ b/www/components/type/jsx.tsx @@ -0,0 +1,405 @@ +import { JSXElement } from "revolution/jsx-runtime"; +import { type Operation } from "effection"; +import type { + DocNode, + ParamDef, + TsTypeDef, + TsTypeParamDef, + TsTypeRefDef, + VariableDef, +} from "@deno/doc"; +import { + Builtin, + ClassName, + Keyword, + Operator, + Optional, + Punctuation, +} from "./tokens.tsx"; + +interface TypeProps { + node: DocNode; +} + +export function* Type(props: TypeProps): Operation { + const { node } = props; + + switch (node.kind) { + case "function": + return ( + + {node.functionDef.isAsync + ? {"async "} + : <>} + {node.kind} + {node.functionDef.isGenerator ? * : <>} + {" "} + {node.name} + {node.functionDef.typeParams.length > 0 + ? + : <>} + ( + + ): {node.functionDef.returnType + ? + : <>} + + ); + case "class": + return ( + + {node.kind} {node.name} + {node.classDef.extends + ? ( + <> + {" extends "} + {node.classDef.extends} + + ) + : <>} + {node.classDef.implements + ? ( + <> + {" implements "} + <> + {node.classDef.implements + .flatMap((typeDef) => [, ", "]) + .slice(0, -1)} + + + ) + : <>} + + ); + case "interface": + return ( + + {node.kind} {node.name} + {node.interfaceDef.typeParams.length > 0 + ? + : <>} + {node.interfaceDef.extends.length > 0 + ? ( + <> + {" extends "} + <> + {node.interfaceDef.extends + .flatMap((typeDef) => [, ", "]) + .slice(0, -1)} + + + ) + : <>} + + ); + case "variable": + return ( + + + + ); + case "typeAlias": + return ( + + {"type "} + {node.name} + {" = "} + + + ); + case "enum": + case "import": + case "moduleDoc": + case "namespace": + default: + console.log(" unimplemented", node.kind); + return ( + + {node.kind} {node.name} + + ); + } +} + +function TSVariableDef({ + variableDef, + name, +}: { + variableDef: VariableDef; + name: string; +}) { + return ( + <> + {variableDef.kind} {name} + :{" "} + {variableDef.tsType ? : <>} + + ); +} + +function FunctionParams({ params }: { params: ParamDef[] }) { + return ( + <> + {params + .flatMap((param) => [, ", "]) + .slice(0, -1)} + + ); +} + +function TSParam({ param }: { param: ParamDef }) { + switch (param.kind) { + case "identifier": { + return ( + <> + {param.name} + + {": "} + {param.tsType ? : <>} + + ); + } + case "rest": { + return ( + <> + + + {param.tsType ? : <>} + + ); + } + default: + console.log(" unimplemented:", param); + } + return <>; +} + +export function TypeDef({ typeDef }: { typeDef: TsTypeDef }) { + switch (typeDef.kind) { + case "literal": + switch (typeDef.literal.kind) { + case "string": + return "{typeDef.repr}"; + case "number": + return {typeDef.repr}; + case "boolean": + return {typeDef.repr}; + case "bigInt": + return {typeDef.repr}; + default: + // TODO(taras): implement template + return <>; + } + case "keyword": + if (["number", "string", "boolean", "bigint"].includes(typeDef.keyword)) { + return {typeDef.keyword}; + } else { + return {typeDef.keyword}; + } + case "typeRef": + return ; + case "union": + return ; + case "fnOrConstructor": + if (typeDef.fnOrConstructor.constructor) { + console.log(` unimplemeneted`, typeDef.fnOrConstructor); + // TODO(taras): implement + return <>; + } else { + return ( + <> + ( + + ) + {" => "} + + + ); + } + case "indexedAccess": + return ( + <> + + [ + + ] + + ); + case "tuple": + return ( + <> + [ + <> + {typeDef.tuple + .flatMap((tp) => [, ", "]) + .slice(0, -1)} + + ] + + ); + case "array": + return ( + <> + + [] + + ); + case "typeOperator": + return ( + <> + {typeDef.typeOperator.operator}{" "} + + + ); + case "parenthesized": { + return ( + <> + ( + + ) + + ); + } + case "intersection": { + return ( + <> + {typeDef.intersection + .flatMap((tp) => [ + , + {" & "}, + ]) + .slice(0, -1)} + + ); + } + case "typeLiteral": { + // todo(taras): this is incomplete + return ( + <> + { + } + + ); + } + case "conditional": { + return ( + <> + + {" extends "} + + {" ? "} + + {" : "} + + + ); + } + case "infer": { + return ( + <> + {"infer "} + {typeDef.infer.typeParam.name} + + ); + } + case "mapped": + return ( + <> + [ + {typeDef.mappedType.typeParam.name} + {` in `} + {typeDef.mappedType.typeParam.constraint + ? + : <>} + ] + {" : "} + {typeDef.mappedType.tsType + ? + : <>} + + ); + case "importType": + case "optional": + case "rest": + case "this": + case "typePredicate": + case "typeQuery": + console.log(" unimplemented", typeDef); + } + return <>; +} + +function TypeDefUnion({ union }: { union: TsTypeDef[] }) { + return ( + <> + {union.flatMap((typeDef, index) => ( + <> + + {index + 1 < union.length ? {" | "} : <>} + + ))} + + ); +} + +function TypeRef({ typeRef }: { typeRef: TsTypeRefDef }) { + return ( + <> + {typeRef.typeName} + {typeRef.typeParams + ? ( + <> + {"<"} + <> + {typeRef.typeParams + .flatMap((tp) => [, ", "]) + .slice(0, -1)} + + {">"} + + ) + : <>} + + ); +} + +function InterfaceTypeParams({ + typeParams, +}: { + typeParams: TsTypeParamDef[]; +}): JSXElement { + return ( + <> + {"<"} + <> + {typeParams + .flatMap((param) => { + return [ + <> + {param.name} + {param.constraint + ? ( + <> + {" extends "} + + + ) + : <>} + {param.default + ? ( + <> + {" = "} + + + ) + : <>} + , + ", ", + ]; + }) + .slice(0, -1)} + + {">"} + + ); +} diff --git a/www/components/type/markdown.tsx b/www/components/type/markdown.tsx new file mode 100644 index 000000000..3b1dda457 --- /dev/null +++ b/www/components/type/markdown.tsx @@ -0,0 +1,410 @@ +import { Operation } from "effection"; +import type { + ClassMethodDef, + DocNode, + ParamDef, + TsTypeDef, + TsTypeParamDef, +} from "@deno/doc"; +import { toHtml } from "hast-util-to-html"; +import { DocPage } from "../../hooks/use-deno-doc.tsx"; +import { Icon } from "./icon.tsx"; + +const NEW = + `new`; +const OPTIONAL = + `optional`; +const READONLY = + `readonly`; + +export const NO_DOCS_AVAILABLE = "*No documentation available.*"; + +export function* extract( + node: DocNode, +): Operation<{ markdown: string; ignore: boolean; pages: DocPage[] }> { + const lines = []; + const pages: DocPage[] = []; + + let ignore = false; + + if (node.jsDoc && node.jsDoc.doc) { + lines.push(node.jsDoc.doc); + } + + const deprecated = node.jsDoc && + node.jsDoc.tags?.flatMap((tag) => (tag.kind === "deprecated" ? [tag] : [])); + if (deprecated && deprecated.length > 0) { + lines.push(``); + for (const warning of deprecated) { + if (warning.doc) { + lines.push( + `
    + Deprecated + + ${warning.doc} + +
    + `, + ); + } + } + } + + const examples = node.jsDoc && + node.jsDoc.tags?.flatMap((tag) => (tag.kind === "example" ? [tag] : [])); + if (examples && examples?.length > 0) { + lines.push("### Examples"); + let i = 1; + for (const example of examples) { + lines.push(`#### Example ${i++}`, example.doc, "---"); + } + } + + if (node.kind === "class") { + if (node.classDef.constructors.length > 0) { + lines.push(`### Constructors`, "
    "); + for (const constructor of node.classDef.constructors) { + lines.push( + `
    ${NEW} **${node.name}**(${ + constructor.params + .map(Param) + .join(", ") + })
    `, + `
    `, + constructor.jsDoc, + `
    `, + ); + } + lines.push("
    "); + } + + const nonStatic = node.classDef.methods.filter( + (method) => !method.isStatic, + ); + if (nonStatic.length > 0) { + lines.push("### Methods", `
    `, ...methodList(nonStatic), "
    "); + } + + const staticMethods = node.classDef.methods.filter( + (method) => method.isStatic, + ); + if (staticMethods.length > 0) { + lines.push( + "### Static Methods", + "
    ", + ...methodList(staticMethods), + "
    ", + ); + } + } + + if (node.kind === "namespace") { + const variables = node.namespaceDef.elements.flatMap((node) => + node.kind === "variable" ? [node] : [] + ) ?? []; + if (variables.length > 0) { + lines.push("### Variables"); + lines.push("
    "); + for (const variable of variables) { + const name = `${node.name}.${variable.name}`; + const section = yield* extract(variable); + const description = variable.jsDoc?.doc || NO_DOCS_AVAILABLE; + pages.push({ + name, + kind: variable.kind, + description, + dependencies: [], + sections: [ + { + id: exportHash(variable, 0), + node: variable, + markdown: section.markdown, + ignore: section.ignore, + }, + ], + }); + lines.push( + `
    `, + toHtml(), + `[${name}](${name})`, + `
    `, + ); + lines.push(`
    `, description, `
    `); + } + lines.push("
    "); + } + } + + if (node.kind === "interface") { + if (node.name === "Completed") console.log(node); + + lines.push("\n", ...TypeParams(node.interfaceDef.typeParams, node)); + + if (node.interfaceDef.properties.length > 0) { + lines.push("### Properties", "
    "); + for (const property of node.interfaceDef.properties) { + const typeDef = property.tsType ? TypeDef(property.tsType) : ""; + const description = property.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `
    **${property.name}**${ + property.readonly ? READONLY : "" + }${property.optional ? OPTIONAL : ""}: ${typeDef}
    `, + `
    `, + description, + "
    ", + ); + } + lines.push("
    "); + } + + if (node.interfaceDef.methods.length > 0) { + lines.push("### Methods", "
    "); + for (const method of node.interfaceDef.methods) { + const typeParams = method.typeParams.map(TypeParam).join(", "); + const params = method.params.map(Param).join(", "); + const returnType = method.returnType ? TypeDef(method.returnType) : ""; + const description = method.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `

    ${method.name}

    ${ + typeParams ? `<${typeParams}>` : "" + }(${params}): ${returnType}
    `, + `
    `, + description, + "
    ", + ); + } + lines.push("
    "); + } + } + + if (node.kind === "typeAlias") { + lines.push("\n", ...TypeParams(node.typeAliasDef.typeParams, node)); + } + + if (node.kind === "function") { + lines.push(...TypeParams(node.functionDef.typeParams, node)); + + const { params } = node.functionDef; + if (params.length > 0) { + lines.push("### Parameters"); + const jsDocs = node.jsDoc?.tags?.flatMap((tag) => + tag.kind === "param" ? [tag] : [] + ) ?? []; + let i = 0; + for (const param of params) { + lines.push("\n", Param(param)); + if (jsDocs[i] && jsDocs[i].doc) { + lines.push("\n", jsDocs[i].doc); + } + i++; + } + } + + if (node.functionDef.returnType) { + lines.push("### Return Type", "\n", TypeDef(node.functionDef.returnType)); + const jsDocs = node.jsDoc?.tags?.find((tag) => tag.kind === "return"); + if (jsDocs && jsDocs.doc) { + lines.push("\n", jsDocs.doc); + } + } + } + + if (node.kind === "variable" && node.variableDef.tsType) { + lines.push("### Type", "\n", TypeDef(node.variableDef.tsType)); + } + + const see: string[] = []; + if (node.jsDoc && node.jsDoc.tags) { + for (const tag of node.jsDoc.tags) { + switch (tag.kind) { + case "ignore": { + ignore = true; + break; + } + case "see": { + see.push(tag.doc); + } + } + } + } + if (see.length > 0) { + lines.push("\n", "### See", ...see.map((item) => `* ${item}`)); + } + + const markdown = lines.join("\n"); + + return { + markdown, + ignore, + pages, + }; +} + +export function exportHash(node: DocNode, index: number): string { + return [node.kind, node.name, index].filter(Boolean).join("_"); +} + +export function TypeParams(typeParams: TsTypeParamDef[], node: DocNode) { + let lines = []; + if (typeParams.length > 0) { + lines.push("### Type Parameters"); + const jsDocs = node.jsDoc?.tags?.flatMap((tag) => + tag.kind === "template" ? [tag] : [] + ) ?? []; + let i = 0; + for (const typeParam of typeParams) { + lines.push(TypeParam(typeParam)); + if (jsDocs[i]) { + lines.push(jsDocs[i].doc); + } + lines.push("\n"); + i++; + } + } + return lines; +} + +export function TypeDef(typeDef: TsTypeDef): string { + switch (typeDef.kind) { + case "fnOrConstructor": { + const params = typeDef.fnOrConstructor.params.map(Param).join(", "); + const tparams = typeDef.fnOrConstructor.typeParams + .map(TypeParam) + .join(", "); + return `${tparams.length > 0 ? `<${tparams}>` : ""}(${params}) => ${ + TypeDef( + typeDef.fnOrConstructor.tsType, + ) + }`; + } + case "typeRef": { + const tparams = typeDef.typeRef.typeParams?.map(TypeDef).join(", "); + return `{@link ${typeDef.typeRef.typeName}}${ + tparams && tparams?.length > 0 ? `<${tparams}>` : "" + }`; + } + case "keyword": { + return typeDef.keyword; + } + case "union": { + return typeDef.union.map(TypeDef).join(" | "); + } + case "array": { + return `${TypeDef(typeDef.array)}[]`; + } + case "typeOperator": { + return `${typeDef.typeOperator.operator} ${ + TypeDef( + typeDef.typeOperator.tsType, + ) + }`; + } + case "tuple": { + return `[${typeDef.tuple.map(TypeDef).join(", ")}]`; + } + case "parenthesized": { + return TypeDef(typeDef.parenthesized); + } + case "intersection": { + return typeDef.intersection.map(TypeDef).join(" & "); + } + case "typeLiteral": { + // todo(taras): this is incomplete + return `{}`; + } + case "mapped": { + return `[${TypeParam(typeDef.mappedType.typeParam)}]: ${ + typeDef.mappedType.tsType ? TypeDef(typeDef.mappedType.tsType) : "" + }`; + } + case "conditional": { + return `${TypeDef(typeDef.conditionalType.checkType)} extends ${ + TypeDef( + typeDef.conditionalType.extendsType, + ) + } ? ${ + TypeDef( + typeDef.conditionalType.trueType, + ) + } : ${TypeDef(typeDef.conditionalType.falseType)}`; + } + case "indexedAccess": { + return `${TypeDef(typeDef.indexedAccess.objType)}[${ + TypeDef( + typeDef.indexedAccess.indexType, + ) + }]`; + } + case "literal": { + return `*${typeDef.repr}*`; + } + case "importType": + case "infer": + case "optional": + case "rest": + case "this": + case "typePredicate": + case "typeQuery": + console.log("TypeDef: unimplemented", typeDef); + } + return ""; +} + +function TypeParam(paramDef: TsTypeParamDef) { + let parts = [`{@link ${paramDef.name}}`]; + if (paramDef.constraint) { + if ( + paramDef.constraint.kind === "typeOperator" && + paramDef.constraint.typeOperator.operator === "keyof" + ) { + parts.push(`in ${TypeDef(paramDef.constraint)}`); + } else { + parts.push(`extends ${TypeDef(paramDef.constraint)}`); + } + } + if (paramDef.default) { + parts.push(`= ${TypeDef(paramDef.default)}`); + } + return parts.join(" "); +} + +function Param(paramDef: ParamDef): string { + switch (paramDef.kind) { + case "identifier": { + return `**${paramDef.name}**${paramDef.optional ? OPTIONAL : ""}: ${ + paramDef.tsType ? TypeDef(paramDef.tsType) : "" + }`; + } + case "rest": { + return `...${Param(paramDef.arg)} ${ + paramDef.tsType ? TypeDef(paramDef.tsType) : "" + }`; + } + case "assign": + case "array": + case "object": + console.log("Param: unimplemented", paramDef); + } + return ""; +} + +export function methodList(methods: ClassMethodDef[]) { + const lines = []; + for (const method of methods) { + const typeParams = method.functionDef.typeParams.map(TypeParam).join(", "); + const params = method.functionDef.params.map(Param).join(", "); + const returnType = method.functionDef.returnType + ? TypeDef(method.functionDef.returnType) + : ""; + const description = method.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `
    **${method.name}**${ + typeParams ? `<${typeParams}>` : "" + }(${params}): ${returnType}
    `, + `
    `, + description, + "
    ", + ); + } + return lines; +} diff --git a/www/components/type/tokens.tsx b/www/components/type/tokens.tsx new file mode 100644 index 000000000..bfad59672 --- /dev/null +++ b/www/components/type/tokens.tsx @@ -0,0 +1,37 @@ +import type { JSXChild, JSXElement } from "revolution"; + +export function ClassName({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Punctuation( + { children, classes, style }: { + children: JSXChild; + classes?: string; + style?: string; + }, +): JSXElement { + return ( + {children} + ); +} + +export function Operator({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Keyword({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Builtin({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Optional({ optional }: { optional: boolean }): JSXElement { + if (optional) { + return ?; + } else { + return <>; + } +} diff --git a/www/context/context-api.ts b/www/context/context-api.ts new file mode 100644 index 000000000..4b49ae08f --- /dev/null +++ b/www/context/context-api.ts @@ -0,0 +1,73 @@ +// deno-lint-ignore-file no-explicit-any ban-types +import { createContext, type Operation } from "effection"; + +export type Around = { + [K in keyof Operations]: A[K] extends + (...args: infer TArgs) => infer TReturn ? Middleware + : Middleware<[], A[K]>; +}; + +export interface Middleware { + (args: TArgs, next: (...args: TArgs) => TReturn): TReturn; +} + +export interface Api { + operations: Operations; + around: (around: Partial>) => Operation; +} + +export type Operations = { + [K in keyof T]: T[K] extends ((...args: infer TArgs) => infer TReturn) + ? (...args: TArgs) => TReturn + : T[K] extends Operation ? Operation + : never; +}; + +export function createApi(name: string, handler: A): Api { + let fields = Object.keys(handler) as (keyof A)[]; + + let middleware: Around = fields.reduce((sum, field) => { + return Object.assign(sum, { + [field]: (args: any, next: any) => next(...args), + }); + }, {} as Around); + + let context = createContext>(`$api:${name}`, middleware); + + let operations = fields.reduce((api, field) => { + let handle = handler[field]; + if (typeof handle === "function") { + return Object.assign(api, { + [field]: function* (...args: any[]) { + let around = yield* context.expect(); + let middleware = around[field] as Function; + return yield* middleware(args, handle); + }, + }); + } else { + return Object.assign(api, { + [field]: { + *[Symbol.iterator]() { + let around = yield* context.expect(); + let middleware = around[field] as Function; + return yield* middleware([], () => handle); + }, + }, + }); + } + }, {} as Operations); + + function* around(around: Partial>): Operation { + let current = yield* context.expect(); + yield* context.set(fields.reduce((sum, field) => { + let prior = current[field] as Middleware; + let middleware = around[field] as Middleware; + return Object.assign(sum, { + [field]: (args: any, next: any) => + middleware(args, (...args) => prior(args, next)), + }); + }, Object.assign({}, current))); + } + + return { operations, around }; +} diff --git a/www/context/doc-page.ts b/www/context/doc-page.ts new file mode 100644 index 000000000..bffa404ae --- /dev/null +++ b/www/context/doc-page.ts @@ -0,0 +1,4 @@ +import { createContext } from "effection"; +import type { DocPage } from "../hooks/use-deno-doc.tsx"; + +export const DocPageContext = createContext("doc-page"); diff --git a/www/context/fetch.ts b/www/context/fetch.ts new file mode 100644 index 000000000..9adff6168 --- /dev/null +++ b/www/context/fetch.ts @@ -0,0 +1,76 @@ +import { Operation, until } from "effection"; +import { createApi } from "./context-api.ts"; +import { rewrite } from "./url-rewrite.ts"; +import { log } from "./logging.ts"; + +interface FetchApi { + fetch(input: RequestInfo | URL, init?: RequestInit): Operation; +} + +export const fetchApi = createApi("fetch", { + *fetch(input, init) { + return yield* until(globalThis.fetch(input, init)); + }, +}); + +export const { operations } = fetchApi; + +export function* initFetch() { + const cache = yield* until(caches.open("local-cache")); + + yield* fetchApi.around({ + *fetch([input, init], next) { + let request = input instanceof Request ? input : new Request(input, init); + if (request.method === "GET") { + const response = yield* until(cache.match(request)); + if (response) { + return response; + } else { + const response = yield* next(input, init); + yield* until(cache.put(request, response.clone())); + return response; + } + } + return yield* next(input, init); + }, + }); + + yield* fetchApi.around({ + *fetch([input, init], next) { + const url = input instanceof Request + ? new URL(input.url) + : new URL(input); + + if (url.protocol === "file:") { + yield* log.debug(`Reading file system file from ${url}`); + try { + const file = yield* until(Deno.open(url.pathname)); + return new Response(file.readable); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return new Response(`File not found ${url.pathname}`, { + status: 404, + }); + } + console.error(`Error reading file ${url.pathname}:`, error); + return new Response("Internal server error", { status: 500 }); + } + } else { + return yield* next(input, init); + } + }, + }); + + yield* fetchApi.around({ + *fetch([input, init], next) { + const url = input instanceof Request + ? new URL(input.url) + : new URL(input); + const newUrl = yield* rewrite(url, input, init); + if (url !== newUrl) { + yield* log.debug(`Rewrite ${url} to ${newUrl}`); + } + return yield* next(newUrl, init); + }, + }); +} diff --git a/www/context/jsr.ts b/www/context/jsr.ts new file mode 100644 index 000000000..86fa00065 --- /dev/null +++ b/www/context/jsr.ts @@ -0,0 +1,19 @@ +import { createContext, type Operation } from "effection"; +import { createJSRClient, JSRClient } from "../resources/jsr-client.ts"; + +const JSRClientContext = createContext("jsr-client"); + +export function* initJSRClient() { + const token = Deno.env.get("JSR_API") ?? ""; + if (token === "") { + console.log("Missing JSR API token; expect score card not to load."); + } + + let client = yield* createJSRClient(token); + + return yield* JSRClientContext.set(client); +} + +export function* useJSRClient(): Operation { + return yield* JSRClientContext.expect(); +} diff --git a/www/context/logging.ts b/www/context/logging.ts new file mode 100644 index 000000000..5dcf9beed --- /dev/null +++ b/www/context/logging.ts @@ -0,0 +1,91 @@ +import type { Operation } from "effection"; +import { createApi } from "./context-api.ts"; + +export interface Logger { + info: (message: string, ...args: unknown[]) => Operation; + debug: (message: string, ...args: unknown[]) => Operation; + warn: (message: string, ...args: unknown[]) => Operation; + error: (message: string, ...args: unknown[]) => Operation; +} + +export const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + gray: "\x1b[90m", + green: "\x1b[32m", +}; + +const consoleLogger: Logger = { + *info(message: string, ...args: unknown[]) { + console.log(`${colors.blue}[INFO]${colors.reset} ${message}`, ...args); + }, + *debug(message: string, ...args: unknown[]) { + console.log(`${colors.gray}[DEBUG]${colors.reset} ${message}`, ...args); + }, + *warn(message: string, ...args: unknown[]) { + console.warn(`${colors.yellow}[WARN]${colors.reset} ${message}`, ...args); + }, + *error(message: string, ...args: unknown[]) { + console.error(`${colors.red}[ERROR]${colors.reset} ${message}`, ...args); + }, +}; + +export const loggerApi = createApi("logger", consoleLogger); +export const log = loggerApi.operations; + +export function* verboseLogging(verbose: boolean) { + yield* loggerApi.around({ + *info(args, next) { + yield* next(...args); + }, + *warn(args, next) { + if (verbose) { + yield* next(...args); + } + }, + *debug(args, next) { + if (verbose) { + yield* next(...args); + } + }, + *error(args, next) { + yield* next(...args); + }, + }); +} + +export function* namespace(namespace: string) { + yield* loggerApi.around({ + *info(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *warn(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *debug(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *error(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + }); +} + +export function* indent() { + yield* loggerApi.around({ + *info(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *warn(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *debug(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *error(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + }); +} diff --git a/www/context/process.ts b/www/context/process.ts new file mode 100644 index 000000000..b53d5521b --- /dev/null +++ b/www/context/process.ts @@ -0,0 +1,160 @@ +import type { Operation, Stream } from "effection"; +import { each, spawn, until, withResolvers } from "effection"; +import md5 from "md5"; +import { regex } from "arktype"; +import { createApi } from "./context-api.ts"; +import { exec, ExecOptions, ProcessResult } from "@effectionx/process"; +import { log } from "./logging.ts"; +import { cwd, useCwd } from "./shell.ts"; +import { fileURLToPath } from "node:url"; + +export interface ProcessApi { + useProcess(command: string, options?: ExecOptions): Operation; +} + +export const processApi = createApi("process", { + *useProcess(command: string, options): Operation { + const cwd = yield* useCwd(); + return yield* exec(command, { + cwd, + ...options, + }).expect(); + }, +}); + +export const { useProcess } = processApi.operations; + +export function* drain(source: Stream): Operation { + const complete = withResolvers(); + yield* spawn(function* () { + let chunks = ""; + for (const chunk of yield* each(source)) { + chunks += chunk; + yield* each.next(); + } + complete.resolve(chunks); + }); + + return yield* complete.operation; +} + +export function urlFromCommand(command: string): URL { + return new URL(`https://cache.local/${md5(command)}`); +} + +export function* ProcessOutputCache(patterns: RegExp[]): Operation { + const cache = yield* until(caches.open("command-cache")); + + yield* processApi.around({ + *useProcess([command], next) { + // Check if command matches any of the patterns + const shouldCache = patterns.some((pattern) => pattern.test(command)); + + if (!shouldCache) { + return yield* next(command); + } + + const url = urlFromCommand(command); + + // Check if we have cached result + const cachedResponse = yield* until(cache.match(url)); + if (cachedResponse) { + // Return cached process with cached output + return yield* createCachedProcess(cachedResponse); + } + + // Execute the process normally + const process = yield* next(command); + + yield* until(cache.put(url, new Response(process.stdout))); + + // Fallback to original process if caching failed + return process; + }, + }); +} + +function* createCachedProcess( + cachedResponse: Response, +): Operation { + return { + stdout: yield* until(cachedResponse.text()), + stderr: "", + code: 0, + }; +} + +// Pattern for git show commands with named capture groups +export const gitShowPattern = regex( + "^git show (?[^/]+)/(?[^/]+)/(?[^:]+):(?.+)$", +); + +/** + * Check if the current HEAD is a descendant of a given branch/ref + */ +function* isDescendantOf(ref: string): Operation { + try { + const result = yield* exec( + `git merge-base --is-ancestor ${ref} HEAD`, + ).expect(); + return result.code === 0; + } catch { + return false; + } +} + +/** + * Middleware that intercepts git show commands and reads from filesystem instead + * when the current origin matches and HEAD descends from the target branch + */ +export function* ProcessFileSystemRead( + pattern: typeof gitShowPattern, +): Operation { + yield* processApi.around({ + *useProcess([command], next) { + const match = pattern.exec(command); + + if (!match) { + return yield* next(command); + } + + const { owner, repo, branch, path: filePath } = match.groups; + const repoPath = `${owner}/${repo}`; + const remote = `${owner}/${repo}/${branch}`; + + // Check if origin matches the repository + const originResult = yield* exec("git remote get-url origin").expect(); + const originUrl = originResult.stdout.trim(); + + if (!originUrl.includes(repoPath)) { + yield* log.debug( + `Origin ${originUrl} does not match repository ${repoPath}, executing command normally`, + ); + return yield* next(command); + } + + const isDescendant = yield* isDescendantOf(remote); + + if (!isDescendant) { + yield* log.debug( + `Current HEAD is not a descendant of ${remote}, executing command normally`, + ); + return yield* next(command); + } + + try { + yield* log.debug( + `Reading ${filePath} from filesystem instead of executing: ${command}`, + ); + const basePath = fileURLToPath(new URL("../../", import.meta.url)); + const [process] = yield* cwd(basePath, [next(`cat ${filePath}`)]); + return process; + } catch (error) { + yield* log.debug( + `Failed to read ${filePath}, falling back to command execution: ${error}`, + ); + return yield* next(command); + } + }, + }); +} diff --git a/www/context/request.ts b/www/context/request.ts new file mode 100644 index 000000000..32a657e32 --- /dev/null +++ b/www/context/request.ts @@ -0,0 +1,3 @@ +import { createContext } from "effection"; + +export const CurrentRequest = createContext("Request"); diff --git a/www/context/shell.ts b/www/context/shell.ts new file mode 100644 index 000000000..34c4eed81 --- /dev/null +++ b/www/context/shell.ts @@ -0,0 +1,47 @@ +import { createContext, type Operation, scoped, until } from "effection"; +import { ProcessResult } from "@effectionx/process"; +import { indent, log } from "./logging.ts"; +import { join } from "@std/path"; +import { useProcess } from "./process.ts"; + +const CwdContext = createContext("cwd", Deno.cwd()); + +export function useCwd() { + return CwdContext.expect(); +} + +export function* $(command: string): Operation { + yield* log.debug(`$ ${command}`); + return yield* useProcess(command); +} + +export function* cwd[]>( + directory: string, + ops: T, +): Operation<{ [K in keyof T]: T[K] extends Operation ? R : never }> { + return yield* scoped(function* () { + yield* log.debug(`cwd: ${directory}`); + const result = yield* CwdContext.with(directory, function* () { + yield* indent(); + const results = []; + for (const op of ops) { + results.push(yield* op); + } + // deno-lint-ignore no-explicit-any + return results as any; + }); + return result; + }); +} + +export function* $echo( + data: string | ReadableStream, + filename: string | URL, +): Operation { + const cwd = yield* CwdContext.expect(); + if (typeof filename === "string") { + yield* until(Deno.writeTextFile(join(cwd, filename), data)); + return; + } + yield* until(Deno.writeTextFile(new URL(filename, cwd), data)); +} diff --git a/www/context/url-rewrite.ts b/www/context/url-rewrite.ts new file mode 100644 index 000000000..dbaa782ef --- /dev/null +++ b/www/context/url-rewrite.ts @@ -0,0 +1,18 @@ +import { Operation } from "effection"; +import { createApi } from "./context-api.ts"; + +interface UrlRewrite { + rewrite( + url: URL, + input: RequestInfo | URL, + init?: RequestInit, + ): Operation; +} + +export const urlRewriteApi = createApi("url-rewrite", { + *rewrite(url) { + return url; + }, +}); + +export const { rewrite } = urlRewriteApi.operations; diff --git a/www/deno-deploy-patch.ts b/www/deno-deploy-patch.ts new file mode 100644 index 000000000..457411e89 --- /dev/null +++ b/www/deno-deploy-patch.ts @@ -0,0 +1,26 @@ +/** see https://github.com/denoland/deploy_feedback/issues/527#issuecomment-2510631720 */ +export function patchDenoPermissionsQuerySync() { + const permissions = { + run: "denied", + read: "granted", + write: "denied", + net: "granted", + env: "granted", + sys: "denied", + ffi: "denied", + } as const; + + Deno.permissions.querySync ??= ({ name }) => { + return { + // @ts-expect-error deno-ts(7053) + state: permissions[name], + onchange: null, + partial: false, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + }; + }; +} diff --git a/www/deno.json b/www/deno.json new file mode 100644 index 000000000..fe0b21118 --- /dev/null +++ b/www/deno.json @@ -0,0 +1,81 @@ +{ + "tasks": { + "dev": "deno run -A @effectionx/watch deno run -A main.tsx", + "staticalize": "deno run -A jsr:@frontside/staticalize@0.2.1/cli --site http://localhost:8000 --output=built --base=http://localhost:8000", + "pagefind": "npx pagefind --site built", + "test": "deno test --allow-run --allow-write --allow-read" + }, + "lint": { + "exclude": ["docs/esm"], + "rules": { + "exclude": [ + "prefer-const", + "require-yield", + "jsx-curly-braces", + "jsx-key", + "jsx-no-useless-fragment" + ] + } + }, + "fmt": { + "exclude": ["docs/esm"] + }, + "compilerOptions": { + "lib": ["deno.ns", "dom.iterable", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "revolution" + }, + "imports": { + "@effectionx/watch": "jsr:@effectionx/watch@^0.3.1", + "@frontside/staticalize": "jsr:@frontside/staticalize@0.2.0", + "@jsdevtools/rehype-toc": "npm:@jsdevtools/rehype-toc@3.0.2", + "@libs/xml": "jsr:@libs/xml@^7.0.3", + "@std/assert": "jsr:@std/assert@^1.0.16", + "@std/crypto": "jsr:@std/crypto@^1.0.5", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/http": "jsr:@std/http@^1.0.22", + "@types/hast": "npm:@types/hast@3.0.4", + "effection": "npm:effection@^3.6.1", + "@effectionx/deno-deploy": "npm:@effectionx/deno-deploy@^0.3.1", + "@effectionx/process": "npm:@effectionx/process@^0.6.2", + "hast": "npm:hast@^1.0.0", + "hast-util-shift-heading": "npm:hast-util-shift-heading@4.0.0", + "hast-util-to-html": "npm:hast-util-to-html@9.0.0", + "mdast": "npm:mdast@^3.0.0", + "mdx": "npm:mdx@^0.3.1", + "octokit": "npm:octokit@4.0.3", + "pagefind": "npm:pagefind@1.3.0", + "path-to-regexp": "npm:path-to-regexp@8.2.0", + "rehype-add-classes": "npm:rehype-add-classes@1.0.0", + "rehype-autolink-headings": "npm:rehype-autolink-headings@7.1.0", + "rehype-infer-description-meta": "npm:rehype-infer-description-meta@2.0.0", + "rehype-infer-title-meta": "npm:rehype-infer-title-meta@2.0.0", + "rehype-prism-plus": "npm:rehype-prism-plus@2.0.0", + "rehype-slug": "npm:rehype-slug@6.0.0", + "rehype-stringify": "npm:rehype-stringify@10.0.1", + "remark": "npm:remark@^15.0.1", + "remark-gfm": "npm:remark-gfm@4.0.0", + "remark-parse": "npm:remark-parse@11.0.0", + "remark-rehype": "npm:remark-rehype@11.1.1", + "unified": "npm:unified@11.0.4", + "revolution": "https://deno.land/x/revolution@0.6.1/mod.ts", + "revolution/jsx-runtime": "https://deno.land/x/revolution@0.6.1/jsx-runtime.ts", + "@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.16", + "tailwindcss": "npm:tailwindcss@^4.1.0", + "semver": "npm:semver@7.6.3", + "@deno/doc": "jsr:@deno/doc@0.188.0", + "@deno/graph": "jsr:@deno/graph@0.89.0", + "git-url-parse": "npm:git-url-parse@16.1.0", + "@std/fs": "jsr:@std/fs@1", + "@std/path": "jsr:@std/path@1", + "@std/testing": "jsr:@std/testing@1", + "md5": "npm:md5@^2.3.0", + "expect": "jsr:@std/expect@^1", + "arktype": "npm:arktype@^2", + "_posixNormalize": "https://deno.land/std@0.203.0/path/_normalize.ts", + "hast-util-select": "npm:hast-util-select@6.0.1", + "unist-util-visit": "npm:unist-util-visit@5.0.0", + "vfile": "npm:vfile@6.0.3", + "zod": "npm:zod@3.23.8" + } +} diff --git a/www/e4.ts b/www/e4.ts new file mode 100644 index 000000000..0f2d0cad6 --- /dev/null +++ b/www/e4.ts @@ -0,0 +1,111 @@ +import { + call, + type Operation, + race, + resource, + run, + sleep, + // deno-lint-ignore no-import-prefix +} from "npm:effection@4.0.0-alpha.4"; + +import { + close, + createIndex, + PagefindIndex, + PagefindServiceConfig, + SiteDirectory, + WriteOptions, +} from "pagefind"; +import { staticalize } from "@frontside/staticalize"; +import * as fs from "@std/fs"; + +function exists(path: string | URL, options?: fs.ExistsOptions) { + return call(() => fs.exists(path, options)); +} + +type GenerateOptions = { + host: URL; + publicDir: string; + pagefindDir: string; +} & PagefindServiceConfig; + +const log = (first: unknown, ...args: unknown[]) => + console.log(`πŸ’ͺ: ${first}`, ...args); + +export function generate( + { host, publicDir, pagefindDir, ...indexOptions }: GenerateOptions, +) { + return async function () { + return await run(function* () { + const built = new URL(publicDir, import.meta.url); + + if (yield* exists(built, { isDirectory: true })) { + log(`Reusing existing staticalized ${built.pathname} directory`); + } else { + log(`Staticalizing: ${host} to ${built.pathname}`); + + yield* race([ + staticalize({ + host, + base: host, + dir: built.pathname, + }), + sleep(60000), + ]); + } + + log("Adding index"); + + const index = yield* createPagefindIndex(indexOptions); + + log(`Adding directory: ${built.pathname}`); + + const added = yield* index.addDirectory({ path: built.pathname }); + + log(`Addedd ${added} pages from ${built.pathname}`); + + log(`Writing files ${pagefindDir}`); + return yield* index.writeFiles({ outputPath: pagefindDir }); + }); + }; +} + +export class EPagefindIndex { + constructor(private readonly index: PagefindIndex) {} + + *addDirectory(path: SiteDirectory): Operation { + const response = yield* call(() => this.index.addDirectory(path)); + if (response.errors.length > 0) { + console.error( + `Encountered errors while adding ${path.path}: ${response.errors.join()}`, + ); + } + return response.page_count; + } + + *writeFiles(options?: WriteOptions): Operation { + const response = yield* call(() => this.index.writeFiles(options)); + if (response.errors.length > 0) { + console.error( + `Encountered errors while writing to ${options?.outputPath}: ${response.errors.join()}`, + ); + } + return response.outputPath; + } +} + +export function createPagefindIndex(config?: PagefindServiceConfig) { + return resource(function* (provide) { + const { errors, index } = yield* call(() => createIndex(config)); + + if (!index) { + throw new Error(`Failed to create an index: ${errors.join()}`); + } + + try { + yield* provide(new EPagefindIndex(index)); + } finally { + yield* call(() => close()); + } + }); +} diff --git a/www/hooks/use-deno-doc.tsx b/www/hooks/use-deno-doc.tsx new file mode 100644 index 000000000..f713a22d5 --- /dev/null +++ b/www/hooks/use-deno-doc.tsx @@ -0,0 +1,283 @@ +import { + CacheSetting, + doc, + type DocNode, + type DocOptions, + LoadResponse, +} from "@deno/doc"; +import { call, type Operation, until, useScope } from "effection"; +import { createGraph } from "@deno/graph"; + +import { exportHash, extract } from "../components/type/markdown.tsx"; +import { operations } from "../context/fetch.ts"; +import { DenoJsonSchema } from "../lib/deno-json.ts"; +import { useDescription } from "./use-description-parse.tsx"; + +export type { DocNode }; + +export function* useDenoDoc( + specifiers: string[], + docOptions?: DocOptions, +): Operation> { + let docs = yield* until(doc(specifiers, docOptions)); + return docs; +} + +export interface Dependency { + source: string; + name: string; + version: string; +} + +export interface DocPage { + name: string; + sections: DocPageSection[]; + description: string; + kind: DocNode["kind"]; + dependencies: Dependency[]; +} + +export interface DocPageSection { + id: string; + + node: DocNode; + + markdown?: string; + + ignore: boolean; +} + +export type DocsPages = Record; + +export function* useDocPages(specifier: string): Operation { + const scope = yield* useScope(); + + const loader = (specifier: string) => scope.run(docLoader(specifier)); + const imports = yield* extractImports( + new URL("./deno.json", specifier).toString(), + loader, + ); + + const resolve = imports + ? (specifier: string, referrer: string) => { + let resolved: string = specifier; + if (specifier in imports) { + resolved = imports[specifier]; + } else if (specifier.startsWith(".")) { + resolved = new URL(specifier, referrer).toString(); + } else if (specifier.startsWith("node:")) { + resolved = `npm:@types/node@^22.13.5`; + } + return resolved; + } + : undefined; + + const graph = yield* call(() => + createGraph([specifier], { + load: loader, + resolve, + }) + ); + + const externalDependencies: Dependency[] = graph.modules.flatMap((module) => { + if (module.kind === "external") { + const parts = module.specifier.match(/(.*):(.*)@(.*)/); + if (parts) { + const [, source, name, version] = parts; + return [ + { + source, + name, + version, + }, + ]; + } + } + return []; + }); + + const docs = yield* useDenoDoc([specifier], { + load: loader, + resolve, + }); + + const entrypoints: Record = {}; + + for (const [url, all] of Object.entries(docs)) { + const pages: DocPage[] = []; + for ( + const [symbol, nodes] of Object.entries( + Object.groupBy(all, (node) => node.name), + ) + ) { + if (nodes) { + const sections: DocPageSection[] = []; + for (const node of nodes) { + const { markdown, ignore, pages: _pages } = yield* extract(node); + sections.push({ + id: exportHash(node, sections.length), + node, + markdown, + ignore, + }); + pages.push( + ..._pages.map((page) => ({ + ...page, + dependencies: externalDependencies, + })), + ); + } + + const markdown = sections + .map((s) => s.markdown) + .filter((m) => m) + .join(""); + + const description = yield* useDescription(markdown); + + pages.push({ + name: symbol, + kind: nodes?.at(0)?.kind!, + description, + sections, + dependencies: externalDependencies, + }); + } + } + + entrypoints[url] = pages; + } + + return entrypoints; +} + +function docLoader( + specifier: string, + _isDynamic?: boolean, + _cacheSetting?: CacheSetting, + _checksum?: string, +): () => Operation { + return function* downloadDocModules() { + const url = URL.parse(specifier); + + if (url?.protocol.startsWith("file")) { + const content = yield* until(Deno.readTextFile(url.pathname)); + return { + kind: "module", + specifier, + content, + }; + } + + if (url?.host === "github.com") { + const response = yield* operations.fetch(specifier); + const content = yield* until(response.text()); + if (response.ok) { + return { + kind: "module", + specifier, + content, + }; + } else { + throw new Error(`Could not parse ${specifier} as Github URL`, { + cause: response, + }); + } + } + + if (url?.host === "jsr.io") { + console.log(`Ignoring ${url} while reading docs`); + } + }; +} + +export function isDocsPages(value: unknown): value is DocsPages { + if (typeof value !== "object" || value === null) { + return false; + } + + // Check if each key is a string and value is an array of DocPage objects + for (const key in value) { + if (typeof key !== "string") { + return false; + } + + const pages = (value as Record)[key]; + + if (!Array.isArray(pages)) { + return false; + } + + // Check if each item in the array is a valid DocPage + for (const page of pages) { + if (!isDocPage(page)) { + return false; + } + } + } + + return true; +} + +function isDocPage(value: unknown): value is DocPage { + if (typeof value !== "object" || value === null) { + return false; + } + + const page = value as DocPage; + + return ( + typeof page.name === "string" && + Array.isArray(page.sections) && + page.sections.every(isDocPageSection) && + typeof page.description === "string" && + typeof page.kind === "string" && + Array.isArray(page.dependencies) && + page.dependencies.every(isDependency) + ); +} + +function isDocPageSection(value: unknown): value is DocPageSection { + if (typeof value !== "object" || value === null) { + return false; + } + + const section = value as DocPageSection; + + return ( + typeof section.id === "string" && + typeof section.node === "object" && + section.node !== null && // You might need a guard for DocNode if it's complex + (typeof section.markdown === "undefined" || + typeof section.markdown === "string") && + typeof section.ignore === "boolean" + ); +} + +function isDependency(value: unknown): value is Dependency { + if (typeof value !== "object" || value === null) { + return false; + } + + const dependency = value as Dependency; + + return ( + typeof dependency.source === "string" && + typeof dependency.name === "string" && + typeof dependency.version === "string" + ); +} + +function* extractImports( + url: string, + loader: (specifier: string) => Operation, +) { + const module = yield* loader(url); + if (!module) return; + const content = module.kind === "module" + ? JSON.parse(`${module.content}`) + : undefined; + const { imports } = DenoJsonSchema.parse(content); + + return imports; +} diff --git a/www/hooks/use-description-parse.tsx b/www/hooks/use-description-parse.tsx new file mode 100644 index 000000000..e0a0a07ab --- /dev/null +++ b/www/hooks/use-description-parse.tsx @@ -0,0 +1,35 @@ +import { call, type Operation } from "effection"; +import { unified } from "unified"; +import type { VFile } from "vfile"; +import rehypeInferDescriptionMeta from "rehype-infer-description-meta"; +import rehypeInferTitleMeta from "rehype-infer-title-meta"; +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { trimAfterHR } from "../lib/trim-after-hr.ts"; + +export function* useDescription(markdown: string): Operation { + const file = yield* useMarkdownFile(markdown); + return file.data?.meta?.description ?? ""; +} + +export function* useTitle(markdown: string): Operation { + const file = yield* useMarkdownFile(markdown); + return file.data?.meta?.title ?? ""; +} + +export function* useMarkdownFile(markdown: string): Operation { + return yield* call(() => + unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeStringify) + .use(trimAfterHR) + .use(rehypeInferTitleMeta) + .use(rehypeInferDescriptionMeta, { + inferDescriptionHast: true, + truncateSize: 200, + }) + .process(markdown) + ); +} diff --git a/www/hooks/use-markdown.test.ts b/www/hooks/use-markdown.test.ts new file mode 100644 index 000000000..39f40ef95 --- /dev/null +++ b/www/hooks/use-markdown.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from "@std/assert"; +import { run } from "effection"; +import { createJsDocSanitizer } from "./use-markdown.tsx"; + +const sanitizer = createJsDocSanitizer(); + +function sanitizedEquals(a: string, b: string) { + Deno.test(`${a} => ${b}`, async function () { + const result = await run(function* () { + return yield* sanitizer(a); + }); + assertEquals(result, b); + }); +} + +sanitizedEquals("{@link Context}", "[Context](Context)"); +sanitizedEquals("@{link Scope}", "[Scope](Scope)"); +sanitizedEquals("{@link spawn()}", "[spawn](spawn)"); +sanitizedEquals("{@link Scope.run}", "[Scope.run](Scope.run)"); +sanitizedEquals("{@link Scope#run}", "[Scope#run](Scope#run)"); +sanitizedEquals( + "{@link * establish error boundaries https://frontside.com/effection/docs/errors | error boundaries}", + "", +); +sanitizedEquals( + "{@link Operation}<{@link T}>", + "[Operation](Operation)<[T](T)>", +); diff --git a/www/hooks/use-markdown.tsx b/www/hooks/use-markdown.tsx new file mode 100644 index 000000000..f71bbd6df --- /dev/null +++ b/www/hooks/use-markdown.tsx @@ -0,0 +1,118 @@ +import { call, type Operation } from "effection"; +import rehypeAddClasses from "rehype-add-classes"; +import rehypePrismPlus from "rehype-prism-plus"; +import rehypeSlug from "rehype-slug"; +import remarkGfm from "remark-gfm"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import { JSXElement } from "revolution/jsx-runtime"; +import { removeDescriptionHR } from "../lib/remove-description-hr.ts"; +import { replaceAll } from "../lib/replace-all.ts"; +import { useMDX, UseMDXOptions } from "./use-mdx.tsx"; + +export function* defaultLinkResolver( + symbol: string, + connector?: string, + method?: string, +) { + let parts = [symbol]; + if (symbol && connector && method) { + parts.push(connector, method); + } + const name = parts.filter(Boolean).join(""); + if (name) { + return `[${name}](${name})`; + } + return ""; +} + +export type ResolveLinkFunction = ( + symbol: string, + connector?: string, + method?: string, +) => Operation; + +export type UseMarkdownOptions = UseMDXOptions & { + linkResolver?: ResolveLinkFunction; + slugPrefix?: string; +}; + +export function* useMarkdown( + markdown: string, + options?: UseMarkdownOptions, +): Operation { + /** + * I'm doing this pre-processing here because MDX throws a parse error when it encounteres `{@link }`. + * I can't use a remark/rehype plugin to change this because they are applied after MDX parses is successful. + */ + const sanitize = createJsDocSanitizer( + options?.linkResolver ?? defaultLinkResolver, + ); + const sanitized = yield* sanitize(markdown); + + const mod = yield* useMDX(sanitized, { + remarkPlugins: [remarkGfm, ...(options?.remarkPlugins ?? [])], + rehypePlugins: [ + [removeDescriptionHR], + [ + rehypePrismPlus, + { + showLineNumbers: true, + }, + ], + [ + rehypeSlug, + { + prefix: options?.slugPrefix ? `${options.slugPrefix}-` : undefined, + }, + ], + [ + rehypeAutolinkHeadings, + { + behavior: "append", + properties: { + className: + "opacity-0 group-hover:opacity-100 after:content-['#'] after:ml-1.5 no-underline", + }, + }, + ], + [ + rehypeAddClasses, + { + "h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]": + "group scroll-mt-[100px]", + pre: "grid", + }, + ], + ...(options?.rehypePlugins ?? []), + ], + remarkRehypeOptions: options?.remarkRehypeOptions, + }); + + return yield* call(async () => { + try { + const result = await mod.default(); + return result; + } catch (e) { + console.error( + `Failed to convert markdown to JSXElement for ${markdown}`, + e, + ); + return <>; + } + }); +} + +export function createJsDocSanitizer( + resolver: ResolveLinkFunction = defaultLinkResolver, +) { + return function* sanitizeJsDoc(doc: string) { + return yield* replaceAll( + doc, + /@?{@?link\s*(\w*)([^\w}])?(\w*)?([^}]*)?}/gm, + function* (match) { + const [, symbol, connector, method] = match; + return yield* resolver(symbol, connector, method); + }, + ); + }; +} diff --git a/www/hooks/use-mdx.tsx b/www/hooks/use-mdx.tsx new file mode 100644 index 000000000..1fe75f5b1 --- /dev/null +++ b/www/hooks/use-mdx.tsx @@ -0,0 +1,45 @@ +import { call, type Operation } from "effection"; +// deno-lint-ignore no-import-prefix +import { evaluate } from "npm:@mdx-js/mdx@3.1.0"; +// deno-lint-ignore no-import-prefix +import type { MDXModule } from "npm:@types/mdx@2.0.13"; +import { Fragment, jsx, jsxs } from "revolution/jsx-runtime"; +import { PluggableList } from "unified"; +// deno-lint-ignore no-import-prefix +import type { Options as RemarkRehypeOptions } from "npm:remark-rehype@11.1.1"; + +export interface UseMDXOptions { + remarkPlugins?: PluggableList | null | undefined; + /** + * List of rehype plugins (optional). + */ + rehypePlugins?: PluggableList | null | undefined; + /** + * Options to pass through to `remark-rehype` (optional); + * the option `allowDangerousHtml` will always be set to `true` and the MDX + * nodes (see `nodeTypes`) are passed through; + * In particular, you might want to pass configuration for footnotes if your + * content is not in English. + */ + remarkRehypeOptions?: Readonly | null | undefined; +} + +export function* useMDX( + markdown: string, + options?: UseMDXOptions, +): Operation { + return yield* call(async function () { + try { + return await evaluate(markdown, { + jsx, + jsxs, + jsxDEV: jsx, + Fragment, + ...options, + }); + } catch (e) { + console.log(`Failed to convert markdown to MDX: ${markdown}`); + throw e; + } + }); +} diff --git a/www/lib/clones.ts b/www/lib/clones.ts new file mode 100644 index 000000000..2b5ef4ca4 --- /dev/null +++ b/www/lib/clones.ts @@ -0,0 +1,21 @@ +import { createContext, type Operation } from "effection"; +import { $ } from "../context/shell.ts"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const Clones = createContext("clones"); + +export function* initClones(path: string): Operation { + yield* $(`rm -rf ${path}`); + yield* $(`mkdir -p ${path}`); + yield* Clones.set(path); +} + +export function* useClone(nameWithOwner: string): Operation { + let basepath = yield* Clones.expect(); + let dirpath = resolve(`${basepath}/${nameWithOwner}`); + if (!existsSync(dirpath)) { + yield* $(`git clone https://github.com/${nameWithOwner} ${dirpath}`); + } + return dirpath; +} diff --git a/www/lib/command-parser.test.ts b/www/lib/command-parser.test.ts new file mode 100644 index 000000000..cd604da9f --- /dev/null +++ b/www/lib/command-parser.test.ts @@ -0,0 +1,158 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; +import { parseCommand, splitCommand } from "./command-parser.ts"; + +describe("parseCommand", () => { + // Test cases from minimist-string README + it("solves the main minimist-string problem", function* () { + const result = parseCommand('foo --bar "Hello world!"'); + expect(result).toEqual({ + _: ["foo"], + bar: "Hello world!", + }); + }); + + it("handles escaped quotes correctly", function* () { + const result = parseCommand('foo --bar "Hello \\"world\\"!"'); + expect(result).toEqual({ + _: ["foo"], + bar: 'Hello "world"!', + }); + }); + + it("handles simple quoted string", function* () { + const result = parseCommand('foo --bar "Hello!"'); + expect(result).toEqual({ + _: ["foo"], + bar: "Hello!", + }); + }); + + // Additional comprehensive tests + it("handles simple command without quotes", function* () { + const result = parseCommand("foo --bar hello"); + expect(result).toEqual({ + _: ["foo"], + bar: "hello", + }); + }); + + it("handles multiple arguments", function* () { + const result = parseCommand("command arg1 arg2 --flag --option value"); + expect(result).toEqual({ + _: ["command", "arg1", "arg2"], + flag: true, + option: "value", + }); + }); + + it("handles equals syntax for options", function* () { + const result = parseCommand("command --option=value --flag"); + expect(result).toEqual({ + _: ["command"], + option: "value", + flag: true, + }); + }); + + it("handles short flags", function* () { + const result = parseCommand("command -f -abc value"); + expect(result).toEqual({ + _: ["command"], + f: true, + a: true, + b: true, + c: "value", + }); + }); + + it("handles mixed quotes", function* () { + const result = parseCommand( + `command --single 'Hello world' --double "Hello world"`, + ); + expect(result).toEqual({ + _: ["command"], + single: "Hello world", + double: "Hello world", + }); + }); + + it("handles complex git command", function* () { + const result = parseCommand('git commit -m "Initial commit with spaces"'); + expect(result).toEqual({ + _: ["git", "commit"], + m: "Initial commit with spaces", + }); + }); + + it("handles empty quotes", function* () { + const result = parseCommand('command --empty ""'); + expect(result).toEqual({ + _: ["command"], + empty: "", + }); + }); + + it("handles boolean flags", function* () { + const result = parseCommand("command --verbose --quiet"); + expect(result).toEqual({ + _: ["command"], + verbose: true, + quiet: true, + }); + }); + + it("handles hyphenated options", function* () { + const result = parseCommand("command --dry-run --output-dir /tmp"); + expect(result).toEqual({ + _: ["command"], + "dry-run": true, + "output-dir": "/tmp", + }); + }); +}); + +describe("splitCommand", () => { + it("splits simple command without quotes", function* () { + const result = splitCommand("git status --porcelain"); + expect(result).toEqual(["git", "status", "--porcelain"]); + }); + + it("preserves quoted strings with spaces", function* () { + const result = splitCommand('git commit -m "Initial commit with spaces"'); + expect(result).toEqual([ + "git", + "commit", + "-m", + "Initial commit with spaces", + ]); + }); + + it("handles escaped quotes", function* () { + const result = splitCommand('echo "Hello \\"world\\""'); + expect(result).toEqual(["echo", 'Hello "world"']); + }); + + it("handles mixed quotes", function* () { + const result = splitCommand( + `command --single 'Hello world' --double "Hello world"`, + ); + expect(result).toEqual([ + "command", + "--single", + "Hello world", + "--double", + "Hello world", + ]); + }); + + it("handles empty quotes", function* () { + const result = splitCommand('command --empty ""'); + expect(result).toEqual(["command", "--empty", ""]); + }); + + it("handles multiple spaces", function* () { + const result = splitCommand(" command arg1 arg2 "); + expect(result).toEqual(["command", "arg1", "arg2"]); + }); +}); diff --git a/www/lib/command-parser.ts b/www/lib/command-parser.ts new file mode 100644 index 000000000..30b8782fe --- /dev/null +++ b/www/lib/command-parser.ts @@ -0,0 +1,211 @@ +/** + * Parses a command string into arguments and options + * Modern JavaScript version of minimist-string without external dependencies + */ +export interface ParsedCommand { + _: string[]; + [key: string]: string | true | string[]; +} + +/** + * Splits a command string into an array of arguments, preserving quoted strings + * @param input The command string to split + * @returns Array of command arguments + */ +export function splitCommand(input: string): string[] { + if (!input.includes('"') && !input.includes("'")) { + return input.trim().split(/\s+/); + } + + const wrongPieces = input.split(" "); + let goodPieces = solveQuotes(wrongPieces, '"'); + goodPieces = solveQuotes(goodPieces, "'"); + + // Remove outer quotes but preserve escaped quotes + const regexQuotes = /["']/g; + for (let i = 0; i < goodPieces.length; i++) { + goodPieces[i] = goodPieces[i].replace(/(\\\')/g, "%%%SINGLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(/(\\\")/g, "%%%DOUBLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(regexQuotes, ""); + goodPieces[i] = goodPieces[i].replace(/(%%%SINGLEQUOTE%%%)/g, "'"); + goodPieces[i] = goodPieces[i].replace(/(%%%DOUBLEQUOTE%%%)/g, '"'); + } + + return goodPieces; +} + +/** + * Parses a command string into arguments and options + * @param input The command string to parse + * @returns Parsed command with arguments and options + */ +export function parseCommand(input: string): ParsedCommand { + if (!input.includes('"') && !input.includes("'")) { + // Simple case - no quotes, just split by spaces + return parseSimple(input); + } + + // Complex case - handle quotes + return parseWithQuotes(input); +} + +function parseSimple(input: string): ParsedCommand { + const pieces = input.trim().split(/\s+/); + return parseTokens(pieces); +} + +function parseWithQuotes(input: string): ParsedCommand { + const wrongPieces = input.split(" "); + + let goodPieces = solveQuotes(wrongPieces, '"'); + goodPieces = solveQuotes(goodPieces, "'"); + + // Remove outer quotes but preserve escaped quotes + const regexQuotes = /["']/g; + for (let i = 0; i < goodPieces.length; i++) { + goodPieces[i] = goodPieces[i].replace(/(\\\')/g, "%%%SINGLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(/(\\\")/g, "%%%DOUBLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(regexQuotes, ""); + goodPieces[i] = goodPieces[i].replace(/(%%%SINGLEQUOTE%%%)/g, "'"); + goodPieces[i] = goodPieces[i].replace(/(%%%DOUBLEQUOTE%%%)/g, '"'); + } + + return parseTokens(goodPieces); +} + +function countQuotes(piece: string, quoteChar: string): number { + const regex = new RegExp(`[^${quoteChar}\\\\]`, "g"); + const replaced = piece.replace(regex, ""); + return replaced + .replace(new RegExp(`(\\\\${quoteChar})`, "g"), "") + .replace(/\\/g, "").length; +} + +function hasQuote(piece: string, quoteChar: string): boolean { + return countQuotes(piece, quoteChar) > 0; +} + +function getFirstQuote(piece: string, quoteChar: string, position = 0): number { + let i = position - 1; + do { + i = piece.indexOf(quoteChar, i + 1); + } while (piece.charAt(i - 1) === "\\"); + return i; +} + +function splitPiece(piece: string, quoteChar: string): [string, string] { + const firstQIndex = getFirstQuote(piece, quoteChar); + const secondQIndex = getFirstQuote(piece, quoteChar, firstQIndex + 1); + + const firstPart = piece.substring(0, secondQIndex + 1); + const secondPart = piece.substring(secondQIndex + 1); + + return [firstPart, secondPart]; +} + +function solveQuotes(pieces: string[], quoteChar: string): string[] { + let unclosedQuote = false; + const result: string[] = []; + + for (let i = 0; i < pieces.length; i++) { + if (unclosedQuote) { + if (hasQuote(pieces[i], quoteChar)) { + const qIndex = getFirstQuote(pieces[i], quoteChar); + if (qIndex !== pieces[i].length - 1) { + // Closing quote is not the last character + pieces[i + 1] = pieces[i].substring(qIndex + 1) + + (pieces[i + 1] !== undefined ? pieces[i + 1] : ""); + pieces[i] = pieces[i].substring(0, qIndex + 1); + } + + result[result.length - 1] = result[result.length - 1] + " " + pieces[i]; + unclosedQuote = false; + } else { + result[result.length - 1] = result[result.length - 1] + " " + pieces[i]; + } + } else { + if (hasQuote(pieces[i], quoteChar)) { + const quoteCount = countQuotes(pieces[i], quoteChar); + + if (quoteCount === 1) { + result.push(pieces[i]); + unclosedQuote = true; + } else if (quoteCount === 2) { + const split = splitPiece(pieces[i], quoteChar); + result.push(split[0]); + if (split[1] !== "") result.push(split[1]); + } else { + let next = pieces[i]; + do { + const split = splitPiece(next, quoteChar); + result.push(split[0]); + next = split[1]; + } while (countQuotes(next, quoteChar) > 2); + + if (countQuotes(next, quoteChar) === 1) { + result.push(next); + unclosedQuote = true; + } else if (countQuotes(next, quoteChar) === 2) { + result.push(next); + } else { + throw new Error( + "Unexpected behavior in command parsing. Please report this bug.", + ); + } + } + } else { + result.push(pieces[i]); + } + } + } + return result; +} + +function parseTokens(tokens: string[]): ParsedCommand { + const result: ParsedCommand = { _: [] }; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.startsWith("--")) { + // Long option + const equalIndex = token.indexOf("="); + if (equalIndex !== -1) { + const key = token.substring(2, equalIndex); + const value = token.substring(equalIndex + 1); + result[key] = value; + } else { + const key = token.substring(2); + // Check if next token is a value + if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) { + result[key] = tokens[++i]; + } else { + result[key] = true; + } + } + } else if (token.startsWith("-") && token.length > 1) { + // Short option(s) + const flags = token.substring(1); + + for (let j = 0; j < flags.length; j++) { + const flag = flags[j]; + + if (j === flags.length - 1) { + // Last flag - might have a value + if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) { + result[flag] = tokens[++i]; + } else { + result[flag] = true; + } + } else { + result[flag] = true; + } + } + } else { + // Regular argument + result._.push(token); + } + } + + return result; +} diff --git a/www/lib/deno-json.ts b/www/lib/deno-json.ts new file mode 100644 index 000000000..5bd2fada0 --- /dev/null +++ b/www/lib/deno-json.ts @@ -0,0 +1,21 @@ +import z from "zod"; +import { Operation, until } from "effection"; + +export const DenoJsonSchema = z.object({ + name: z.string().optional(), + version: z.string().optional(), + exports: z.union([z.record(z.string()), z.string()]).optional(), + license: z.string().optional(), + workspace: z.array(z.string()).optional(), + imports: z.record(z.string()).optional(), +}); + +export type DenoJson = z.infer; + +export function* useDenoJson(path: string): Operation { + const { default: json } = yield* until( + import(path, { with: { type: "json" } }), + ); + + return DenoJsonSchema.parse(json); +} diff --git a/www/lib/ensure-trailing-slash.test.ts b/www/lib/ensure-trailing-slash.test.ts new file mode 100644 index 000000000..b6d00f62b --- /dev/null +++ b/www/lib/ensure-trailing-slash.test.ts @@ -0,0 +1,17 @@ +import { assertEquals } from "@std/assert"; +import { ensureTrailingSlash } from "./ensure-trailing-slash.ts"; + +Deno.test("ensureTrailingSlash adds trailing slash to each URL", () => { + assertEquals( + ensureTrailingSlash(new URL("http://example.com/dir")).toString(), + "http://example.com/dir/", + ); + assertEquals( + ensureTrailingSlash(new URL("http://example.com/dir/")).toString(), + "http://example.com/dir/", + ); + assertEquals( + ensureTrailingSlash(new URL("http://example.com/file.json")).toString(), + "http://example.com/file.json", + ); +}); diff --git a/www/lib/ensure-trailing-slash.ts b/www/lib/ensure-trailing-slash.ts new file mode 100644 index 000000000..295032b18 --- /dev/null +++ b/www/lib/ensure-trailing-slash.ts @@ -0,0 +1,7 @@ +export function ensureTrailingSlash(url: URL) { + const isFile = url.pathname.split("/").at(-1)?.includes("."); + if (isFile || url.pathname.endsWith("/")) { + return url; + } + return new URL(`${url.toString()}/`); +} diff --git a/www/lib/fs.ts b/www/lib/fs.ts new file mode 100644 index 000000000..f08cab7d9 --- /dev/null +++ b/www/lib/fs.ts @@ -0,0 +1,10 @@ +import { Operation, until } from "effection"; + +import * as fs from "@std/fs"; + +export function exists( + path: string | URL, + options?: fs.ExistsOptions, +): Operation { + return until(fs.exists(path, options)); +} diff --git a/www/lib/octokit.ts b/www/lib/octokit.ts new file mode 100644 index 000000000..46c94b1ca --- /dev/null +++ b/www/lib/octokit.ts @@ -0,0 +1,37 @@ +import { createContext, Operation, until, useScope } from "effection"; +import { Octokit } from "octokit"; +import { operations } from "../context/fetch.ts"; + +const OctokitContext = createContext("github-client"); + +export function* initOctokitContext() { + const token = Deno.env.get("GITHUB_TOKEN"); + + const scope = yield* useScope(); + + const octokit = new Octokit({ + auth: token, + request: { + fetch: (url: string, init?: RequestInit) => { + return scope.run(() => operations.fetch(url, init)); + }, + }, + }); + + return yield* OctokitContext.set(octokit); +} + +/** + * Get star count for a repository using Octokit + */ +export function* getStarCount(nameWithOwner: string): Operation { + const github = yield* OctokitContext.expect(); + const [owner, name] = nameWithOwner.split("/"); + const response = yield* until( + github.rest.repos.get({ + repo: name, + owner: owner, + }), + ); + return response.data.stargazers_count; +} diff --git a/www/lib/package.ts b/www/lib/package.ts new file mode 100644 index 000000000..2242eed8d --- /dev/null +++ b/www/lib/package.ts @@ -0,0 +1,214 @@ +import { all, Operation, until } from "effection"; +import { DocsPages, useDocPages } from "../hooks/use-deno-doc.tsx"; +import { createRepo, Ref } from "./repo.ts"; +import { extractVersion, findLatestSemverTag } from "./semver.ts"; +import { useWorktree } from "./worktrees.ts"; +import { DenoJson, useDenoJson } from "./deno-json.ts"; +import { + PackageDetailsResult, + PackageScoreResult, +} from "../resources/jsr-client.ts"; +import { useJSRClient } from "../context/jsr.ts"; +import z from "zod"; +import { useMDX } from "../hooks/use-mdx.tsx"; +import { useDescription, useTitle } from "../hooks/use-description-parse.tsx"; + +export type WorkTreePackageOptions = { + type: "worktree"; + series: "v3" | "v4"; +}; + +export type ClonePackageOptions = { + type: "clone"; + name?: string; + path: string; + workspacePath: string; + ref: Ref; +}; + +export type PackageOptions = WorkTreePackageOptions | ClonePackageOptions; + +export interface Package { + name: string; + scopeName: string; + version: string; + workspacePath: string; + ref: Ref; + exports: Record; + entrypoints: Record; + docs: () => Operation; + workspaces: string[]; + jsrPackageDetails: () => Operation< + [ + z.SafeParseReturnType | null, + z.SafeParseReturnType | null, + ] + >; + jsr: URL; + /** + * URL of the package on JSR + */ + jsrBadge: URL; + /** + * URL of package on npm + */ + npm: URL; + /** + * URL of badge for version published on npm + */ + npmVersionBadge: URL; + MDXContent: () => Operation; + title: () => Operation; + description: () => Operation; + readme(): Operation; +} + +//TODO: cache package +export function* usePackage(options: PackageOptions): Operation { + if (options.type === "worktree") { + let repo = createRepo({ name: "effection", owner: "thefrontside" }); + + let tags = yield* repo.tags( + new RegExp(`effection-${options.series}.*`), + ); + + let ref = findLatestSemverTag(tags); + + if (!ref) { + throw new Error(`unable to find package ref for ${options.series}`); + } + + let path = yield* useWorktree(ref.name); + + let denoJson = yield* useDenoJson(`${path}/deno.json`); + + let version = extractVersion(ref.name); + + return yield* initPackage("effection", path, ".", version, ref, denoJson); + } else { + let { path, workspacePath, ref } = options; + let denoJson = yield* useDenoJson(`${path}/deno.json`); + + let name = options.name || denoJson.name || "UNKNOWN_PACKAGE"; + + let version = denoJson.version ?? "main"; + + return yield* initPackage( + name, + path, + workspacePath, + version, + ref, + denoJson, + ); + } +} + +function* initPackage( + name: string, + path: string, + workspacePath: string, + version: string, + ref: Ref, + denoJson: DenoJson, +): Operation { + let [, scope] = denoJson?.name?.match(/@(.*)\/(.*)/) ?? []; + let pkg: Package = { + name, + scopeName: scope, + workspacePath, + version, + ref, + get exports() { + if (typeof denoJson.exports === "string") { + return { ["."]: denoJson.exports }; + } else if (denoJson.exports === undefined) { + return { ["."]: "./mod.ts" }; + } else { + return denoJson.exports; + } + }, + get entrypoints() { + let entrypoints: Record = {}; + for (let key of Object.keys(pkg.exports)) { + entrypoints[key] = new URL(pkg.exports[key], `file://${path}/`); + } + return entrypoints; + }, + *docs() { + let docs: DocsPages = {}; + + for (let [entrypoint, url] of Object.entries(pkg.entrypoints)) { + const pages = yield* useDocPages(`${url}`); + + docs[entrypoint] = pages[`${url}`]; + } + + return docs; + }, + get workspaces() { + return denoJson.workspace ?? []; + }, + jsr: new URL(`./${denoJson.name}/`, "https://jsr.io/"), + jsrBadge: new URL(`./${denoJson.name}`, "https://jsr.io/badges/"), + npm: new URL(`./${denoJson.name}`, "https://www.npmjs.com/package/"), + npmVersionBadge: new URL( + `./${denoJson.name}`, + "https://img.shields.io/npm/v/", + ), + *jsrPackageDetails(): Operation< + [ + z.SafeParseReturnType | null, + z.SafeParseReturnType | null, + ] + > { + let [, packageName] = name.split("/"); + const client = yield* useJSRClient(); + try { + const [details, score] = yield* all([ + client.getPackageDetails({ scope, package: packageName }), + client.getPackageScore({ scope, package: packageName }), + ]); + + if (!details.success) { + console.info( + `JSR package details response failed validation`, + details.error.format(), + ); + } + + if (!score.success) { + console.info( + `JSR score response failed validation`, + score.error.format(), + ); + } + + return [details, score]; + } catch (e) { + console.error(e); + } + + return [null, null]; + }, + + readme: () => until(Deno.readTextFile(`${path}/README.md`)), + + *MDXContent(): Operation { + let readme = yield* pkg.readme(); + let mod = yield* useMDX(readme); + + return mod.default({}); + }, + *title(): Operation { + let readme = yield* pkg.readme(); + return yield* useTitle(readme); + }, + *description(): Operation { + let readme = yield* pkg.readme(); + return yield* useDescription(readme); + }, + }; + + return pkg; +} diff --git a/www/lib/remove-description-hr.ts b/www/lib/remove-description-hr.ts new file mode 100644 index 000000000..7afbec890 --- /dev/null +++ b/www/lib/remove-description-hr.ts @@ -0,0 +1,35 @@ +import type { Nodes } from "hast"; +import { EXIT, visit } from "unist-util-visit"; + +/** + * Remove the HR element used to define the end of the description. + */ +export function removeDescriptionHR() { + return function (tree: Nodes) { + return visit(tree, (node, index, parent) => { + if ( + node.type === "element" && node.tagName === "hr" && + parent?.type === "root" + ) { + const beforeHR = parent.children + .slice(0, index) + .filter((node: Nodes) => + !(node.type === "text" && node.value === "\n") + ); + + // assume this hr is for a description if there are only two elements and + // second element is a paragraph. + if ( + beforeHR.length === 2 && beforeHR[1].type === "element" && + beforeHR[1].tagName === "p" + ) { + parent.children = parent.children.filter((child: Nodes) => + child !== node + ); + } + + return EXIT; + } + }); + }; +} diff --git a/www/lib/replace-all.ts b/www/lib/replace-all.ts new file mode 100644 index 000000000..4efc4694e --- /dev/null +++ b/www/lib/replace-all.ts @@ -0,0 +1,41 @@ +import { Operation } from "effection"; + +export function* replaceAll( + input: string, + regex: RegExp, + replacement: (match: RegExpMatchArray) => Operation, +): Operation { + // replace all implies global, so append if it is missing + const addGlobal = !regex.flags.includes("g"); + let flags = regex.flags; + if (addGlobal) flags += "g"; + + // get matches + let matcher = new RegExp(regex.source, flags); + const matches = Array.from(input.matchAll(matcher)); + + if (matches.length == 0) return input; + + // construct all replacements + let replacements: Array; + replacements = new Array(); + for (let m of matches) { + let r = yield* replacement(m); + replacements.push(r); + } + + // change capturing groups into non-capturing groups for split + // (because capturing groups are added to the parts array + let source = regex.source.replace(/(?; + latest(matching: RegExp): Operation; +} + +export interface Ref { + name: string; + nameWithOwner: string; + url: string; +} + +export interface RepoOptions { + name: string; + owner: string; +} +export function createRepo(options: RepoOptions): Repo { + let { name, owner } = options; + let repo: Repo = { + name, + owner, + *tags(matching) { + let result = yield* $(`git tag`); + let names = result.stdout.trim().split(/\s+/).filter((tag) => + matching.test(tag) + ); + return names.map((tagname) => ({ + name: tagname, + nameWithOwner: `${owner}/${name}`, + url: `https://github.com/${owner}/${name}/tree/${tagname}`, + })); + }, + *latest(matching) { + let tags = yield* repo.tags(matching); + let latest = findLatestSemverTag(tags); + + if (!latest) { + throw new Error(`Could not retrieve latest tag matching ${matching}`); + } + + return tags.find((tag) => tag.name === latest.name)!; + }, + }; + return repo; +} diff --git a/www/lib/semver.ts b/www/lib/semver.ts new file mode 100644 index 000000000..b1d20c3ec --- /dev/null +++ b/www/lib/semver.ts @@ -0,0 +1,27 @@ +export { compare, major, minor, rsort } from "semver"; + +import { rsort } from "semver"; + +export function extractVersion(input: string) { + const parts = input.match( + // @source: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, + ); + if (parts) { + return parts[0]; + } else { + return "0.0.0"; + } +} + +/** + * Find the latest Semver tag from an array of tags + * @param tags - Array of tag objects with name property + * @returns Latest semver tag if found, undefined otherwise + */ +export function findLatestSemverTag( + tags: T[], +): T | undefined { + const [latest] = rsort(tags.map((tag) => tag.name).map(extractVersion)); + return tags.find((tag) => tag.name.endsWith(latest)); +} diff --git a/www/lib/shift-headings.ts b/www/lib/shift-headings.ts new file mode 100644 index 000000000..35da87567 --- /dev/null +++ b/www/lib/shift-headings.ts @@ -0,0 +1,20 @@ +// deno-lint-ignore no-import-prefix +import type { Nodes, Root } from "npm:@types/mdast@4.0.4"; +import { visit } from "unist-util-visit"; + +export function shiftHeadings(amount: number) { + return (tree: Root) => { + visit(tree, (node: Nodes) => { + if (node.type === "heading") { + const depth = node.depth + amount; + if (depth < 1) { + node.depth = 1; + } else if (depth > 6) { + node.depth = 6; + } else { + node.depth = depth as 1 | 2 | 3 | 4 | 5 | 6; + } + } + }); + }; +} diff --git a/www/lib/toc.ts b/www/lib/toc.ts new file mode 100644 index 000000000..c535b71f5 --- /dev/null +++ b/www/lib/toc.ts @@ -0,0 +1,32 @@ +import { Options } from "@jsdevtools/rehype-toc"; +import { createTOC } from "@jsdevtools/rehype-toc/lib/create-toc.js"; +import { customizationHooks } from "@jsdevtools/rehype-toc/lib/customization-hooks.js"; +import { findHeadings } from "@jsdevtools/rehype-toc/lib/fiind-headings.js"; +import { findMainNode } from "@jsdevtools/rehype-toc/lib/find-main-node.js"; +import { NormalizedOptions } from "@jsdevtools/rehype-toc/lib/options.js"; +import type { Nodes } from "hast"; +import { JSXElement } from "revolution/jsx-runtime"; + +export function createToc(root: Nodes, options?: Options): JSXElement { + const _options = new NormalizedOptions( + options ?? { + cssClasses: { + toc: + "hidden text-sm font-light tracking-wide leading-loose lg:block relative pt-2", + link: "hover:underline hover:underline-offset-2", + }, + }, + ); + + // Find the
    or element + let [mainNode] = findMainNode(root); + + // Find all heading elements + let headings = findHeadings(mainNode, _options); + + // Create the table of contents + let tocNode = createTOC(headings, _options); + + // Allow the user to customize the table of contents before we add it to the page + return customizationHooks(tocNode, _options) as unknown as JSXElement; +} diff --git a/www/lib/trim-after-hr.ts b/www/lib/trim-after-hr.ts new file mode 100644 index 000000000..fb1b77ba9 --- /dev/null +++ b/www/lib/trim-after-hr.ts @@ -0,0 +1,24 @@ +import type { Nodes } from "hast"; +import { EXIT, visit } from "unist-util-visit"; + +/** + * Removes all content after
    in the root element. + * This is used to restrict the length of the description by eliminating everything after
    + * @returns + */ +export function trimAfterHR() { + return function (tree: Nodes) { + visit( + tree, + (node: Nodes, index: number | undefined, parent: Nodes | undefined) => { + if ( + node.type === "element" && node.tagName === "hr" && + parent?.type === "root" + ) { + parent.children = parent.children.slice(0, index); + return EXIT; + } + }, + ); + }; +} diff --git a/www/lib/workspace.ts b/www/lib/workspace.ts new file mode 100644 index 000000000..a949c8188 --- /dev/null +++ b/www/lib/workspace.ts @@ -0,0 +1,53 @@ +import { Operation } from "effection"; +import { useClone } from "./clones.ts"; +import { Package, usePackage } from "./package.ts"; + +export interface Workspace { + url: string; + nameWithOwner: string; + root: Package; + packages: Package[]; +} + +export function* useWorkspace(nameWithOwner: string): Operation { + let path = yield* useClone(nameWithOwner); + let [name] = nameWithOwner.split("/"); + + let url = `https://github.com/${nameWithOwner}`; + + let root = yield* usePackage({ + type: "clone", + name, + path, + workspacePath: ".", + ref: { + name: "main", + nameWithOwner, + url: `${url}}/tree/main`, + }, + }); + + let packages: Package[] = []; + + for (let workspacePath of root.workspaces) { + packages.push( + yield* usePackage({ + type: "clone", + path: `${path}/${workspacePath}`, + workspacePath, + ref: { + name: "main", + nameWithOwner, + url: `${url}/tree/main/${workspacePath}`, + }, + }), + ); + } + + return { + url, + nameWithOwner, + root, + packages, + }; +} diff --git a/www/lib/worktrees.ts b/www/lib/worktrees.ts new file mode 100644 index 000000000..9d38bda0a --- /dev/null +++ b/www/lib/worktrees.ts @@ -0,0 +1,21 @@ +import { createContext, type Operation } from "effection"; +import { $ } from "../context/shell.ts"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const Worktrees = createContext("worktrees"); + +export function* initWorktrees(path: string): Operation { + yield* $(`rm -rf ${path}`); + yield* $(`mkdir -p ${path}`); + yield* Worktrees.set(path); +} + +export function* useWorktree(refname: string): Operation { + let basepath = yield* Worktrees.expect(); + let checkout = resolve(`${basepath}/${refname}`); + if (!existsSync(checkout)) { + yield* $(`git worktree add --force ${checkout} ${refname}`); + } + return checkout; +} diff --git a/www/main.css b/www/main.css new file mode 100644 index 000000000..cf0725291 --- /dev/null +++ b/www/main.css @@ -0,0 +1,164 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +/* Tailwind-based Pagefind UI styling */ +@layer components { + .pagefind-ui { + @apply w-full text-gray-900 dark:text-gray-200; + } + + .pagefind-ui__drawer { + @apply flex flex-row; + } + + .pagefind-ui__form { + @apply relative; + } + + .pagefind-ui__form:before { + @apply absolute left-4 top-1/2 w-6 h-6 bg-current; + content: ""; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0'%3E%3C/path%3E%3Cpath d='M21 21l-6 -6'%3E%3C/path%3E%3C/svg%3E"); + width: 18px; + height: 18px; + top: 23px; + left: 20px; + mask-repeat: no-repeat; + mask-size: 100%; + z-index: 9; + pointer-events: none; + display: block; + opacity: 0.7; + } + + .pagefind-ui__search-input { + @apply w-full h-16 px-14 py-4 bg-white dark:bg-gray-800 border-2 + border-gray-200 dark:border-gray-600 rounded-lg text-xl font-bold + placeholder:opacity-20 focus:outline-none focus:border-sky-500 + focus:ring-2 focus:ring-sky-500; + } + + .pagefind-ui__search-clear { + @apply absolute top-1 right-1 h-14 px-4 py-1 text-gray-900 + dark:text-gray-200 text-sm cursor-pointer bg-white dark:bg-gray-800 + rounded-lg; + } + + .pagefind-ui__results { + @apply pl-0 px-0! my-0; + padding-inline-start: 0 !important; + } + + .pagefind-ui__results-area { + @apply flex-1 mt-5 pl-5; + } + + .pagefind-ui__message { + @apply flex items-center h-6 py-2 text-base font-bold mt-0; + } + + .pagefind-ui__result { + @apply list-none flex items-start gap-10 border-t-1 border-gray-200 + dark:border-gray-600; + } + + .pagefind-ui__result:last-of-type { + @apply border-b-1 border-gray-200 dark:border-gray-600; + } + + .pagefind-ui__result-inner { + @apply flex-1 flex flex-col items-start mt-2 py-2; + } + + .pagefind-ui__result-title { + @apply font-bold text-xl my-0!; + } + + .pagefind-ui__result-nested { + @apply pl-5 mb-2; + } + + .pagefind-ui__result-nested:nth-of-type(1) { + @apply mt-2; + } + + .pagefind-ui__result-nested .pagefind-ui__result-title { + @apply text-base; + } + + .pagefind-ui__result-link { + @apply relative text-gray-900 dark:text-gray-200 no-underline! + hover:underline!; + } + + .pagefind-ui__result-nested .pagefind-ui__result-link:before { + content: "\2937 "; + position: absolute; + top: -2; + right: calc(100% + 0.1em); + } + + .pagefind-ui__result-excerpt { + @apply font-normal text-sm mt-1! mb-0!; + } + + .pagefind-ui__result-tags { + @apply list-none p-0 flex gap-5 flex-wrap mt-5; + } + + .pagefind-ui__result-tag { + @apply px-2 py-1 text-sm rounded bg-gray-200 dark:bg-gray-600; + } + + .pagefind-ui__filter-panel { + @apply flex-1 flex flex-col mt-5 max-w-60; + } + + .pagefind-ui__filter-panel-label { + @apply hidden; + } + + .pagefind-ui__filter-block { + @apply block border-b-1 border-gray-200 dark:border-gray-600 py-5; + } + + .pagefind-ui__filter-name { + @apply text-base relative flex items-center list-none font-bold + cursor-pointer h-6; + } + + .pagefind-ui__filter-group { + @apply flex flex-col gap-5 pt-8; + } + + .pagefind-ui__filter-group-label { + @apply hidden; + } + + .pagefind-ui__filter-value { + @apply relative flex items-center gap-2; + } + + .pagefind-ui__filter-checkbox { + @apply m-0 w-4 h-4 border border-gray-200 dark:border-gray-600 + appearance-none rounded bg-white dark:bg-gray-800 cursor-pointer + checked:bg-sky-500 checked:border-sky-500; + } + + .pagefind-ui__filter-label { + @apply cursor-pointer text-base font-normal; + } + + .pagefind-ui__button { + @apply mt-10 w-full h-12 px-3 text-base text-gray-900 dark:text-sky-500 + bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-600 + rounded font-bold cursor-pointer text-center hover:border-sky-500 + hover:text-sky-500; + } + + /* Loading skeleton styles */ + .pagefind-ui__loading { + @apply text-gray-900 dark:text-gray-200 bg-gray-900 dark:bg-gray-200 rounded + opacity-10 pointer-events-none; + } +} diff --git a/www/main.tsx b/www/main.tsx new file mode 100644 index 000000000..a4d6e228a --- /dev/null +++ b/www/main.tsx @@ -0,0 +1,96 @@ +import { initDenoDeploy } from "@effectionx/deno-deploy"; +import { main, suspend } from "effection"; +import { createRevolution, ServerInfo } from "revolution"; + +import { etagPlugin } from "./plugins/etag.ts"; +import { rebasePlugin } from "./plugins/rebase.ts"; +import { route, sitemapPlugin } from "./plugins/sitemap.ts"; +import { tailwindPlugin } from "./plugins/tailwind.ts"; + +import { apiReferenceRoute } from "./routes/api-reference-route.tsx"; +import { assetsRoute } from "./routes/assets-route.ts"; +import { firstPage, guidesRoute } from "./routes/guides-route.tsx"; +import { indexRoute } from "./routes/index-route.tsx"; +import { xIndexRedirect, xIndexRoute } from "./routes/x-index-route.tsx"; +import { xPackageRedirect, xPackageRoute } from "./routes/x-package-route.tsx"; + +import { initFetch } from "./context/fetch.ts"; +import { initJSRClient } from "./context/jsr.ts"; +import { patchDenoPermissionsQuerySync } from "./deno-deploy-patch.ts"; +import { initWorktrees } from "./lib/worktrees.ts"; +import { initGuides } from "./resources/guides.ts"; +import { apiIndexRoute } from "./routes/api-index-route.tsx"; +import { pagefindRoute } from "./routes/pagefind-route.ts"; +import { redirectDocsRoute } from "./routes/redirect-docs-route.tsx"; +import { redirectIndexRoute } from "./routes/redirect-index-route.tsx"; +import { searchRoute } from "./routes/search-route.tsx"; +import { initClones } from "./lib/clones.ts"; +import { initOctokitContext } from "./lib/octokit.ts"; + +// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts +if (import.meta.main) { + await main(function* () { + const denoDeploy = yield* initDenoDeploy(); + + // if (denoDeploy.isDenoDeploy) { + // patchDenoPermissionsQuerySync(); + // } + + yield* initClones("build/clones"); + yield* initWorktrees("build/worktrees"); + yield* initGuides({ + current: "v4", + worktrees: ["v3"], + }); + + yield* initJSRClient(); + yield* initFetch(); + + // configures Octokit client + yield* initOctokitContext(); + + let revolution = createRevolution({ + app: [ + route("/", indexRoute()), + route("/search", searchRoute()), + route("/docs", redirectIndexRoute(firstPage("v3"))), + route("/docs/:id", redirectDocsRoute("v3")), + route("/guides/v3", redirectIndexRoute(firstPage("v3"))), + route("/guides/v4", redirectIndexRoute(firstPage("v4"))), + route("/guides/:series/:id", guidesRoute({ search: true })), + route("/contrib", xIndexRedirect()), + route("/contrib/:workspacePath", xPackageRedirect()), + route("/x", xIndexRoute({ search: true })), + route("/x/:workspacePath", xPackageRoute({ search: true })), + route("/api", apiIndexRoute({ search: true })), + route("/api/v3/:symbol", apiReferenceRoute("v3", { search: true })), + route("/api/v4/:symbol", apiReferenceRoute("v4", { search: true })), + route( + "/pagefind{/*path}", + pagefindRoute({ pagefindDir: "pagefind", publicDir: "./built/" }), + ), + route("/assets/*path", assetsRoute("assets")), + ], + plugins: [ + yield* tailwindPlugin({ input: "main.css", outdir: "tailwind" }), + etagPlugin(), + rebasePlugin(), + sitemapPlugin(), + ], + }); + + let server = yield* revolution.start(); + console.log(`www -> ${urlFromServer(server)}`); + + yield* suspend(); + }); +} + +function urlFromServer(server: ServerInfo) { + return new URL( + "/", + `http://${ + server.hostname === "0.0.0.0" ? "localhost" : server.hostname + }:${server.port}`, + ); +} diff --git a/www/plugins/etag.ts b/www/plugins/etag.ts new file mode 100644 index 000000000..f00051621 --- /dev/null +++ b/www/plugins/etag.ts @@ -0,0 +1,40 @@ +import { RevolutionPlugin } from "revolution"; +import { encodeBase64 } from "@std/encoding/base64"; + +const DEPLOYMENT_ID = + // The same deployment will be shared by the many isolates that serve it + // but because pages do not change, we can use this id as the ETAG + Deno.env.get("DENO_DEPLOYMENT_ID") || + // For local development, just create a new id every time the module is + // reloaded i.e. whenever the dev server restarts. + crypto.randomUUID(); + +const DEPLOYMENT_ID_HASH = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(DEPLOYMENT_ID), +); + +const ETAG = `"${encodeBase64(DEPLOYMENT_ID_HASH)}"`; +const WEAK_ETAG = `W/"${encodeBase64(DEPLOYMENT_ID_HASH)}"`; + +export function etagPlugin(): RevolutionPlugin { + return { + *http(request, next) { + let ifNoneMatch = request.headers.get("if-none-match"); + if (ifNoneMatch === ETAG || ifNoneMatch === WEAK_ETAG) { + return new Response(null, { + status: 304, + statusText: "Not Modified", + }); + } else { + let response = yield* next(request); + if (!response.headers.get("etag")) { + let tagged = new Response(response.body, response); + tagged.headers.set("etag", ETAG); + return tagged; + } + return response; + } + }, + }; +} diff --git a/www/plugins/rebase.ts b/www/plugins/rebase.ts new file mode 100644 index 000000000..bb2f0fb36 --- /dev/null +++ b/www/plugins/rebase.ts @@ -0,0 +1,82 @@ +import type { RevolutionPlugin } from "revolution"; +import { createContext, type Operation } from "effection"; +import { posixNormalize } from "_posixNormalize"; +import { selectAll } from "hast-util-select"; +import { CurrentRequest } from "../context/request.ts"; + +const BaseUrl = createContext("baseUrl"); + +export function rebasePlugin(): RevolutionPlugin { + return { + *http(request, next) { + yield* CurrentRequest.set(request); + + let rebaseUrl = request.headers.get("X-Base-Url") ?? void 0; + if (rebaseUrl) { + yield* BaseUrl.set(new URL(rebaseUrl)); + } else { + let url = new URL(request.url); + url.pathname = "/"; + yield* BaseUrl.set(url); + } + + return yield* next(request); + }, + + /** + * Rebase an HTML document at a different URL. This replaces all `
    ` and + * `` attributes that contain an absolute path. Any path that is + * relative or contains a fully qualitfied URL will be left alone. + * + * @param tree - the HTML tree to transform + * @param baseUrl - a string representing a fully qualified url, e.g. + * http://frontside.com/effection + */ + *html(request, next) { + let tree = yield* next(request); + + let baseUrl = yield* BaseUrl.expect(); + let base = new URL(baseUrl); + let elements = selectAll('[href^="/"],[src^="/"]', tree); + + for (let element of elements) { + let properties = element.properties!; + + if (properties.href) { + properties.href = posixNormalize( + `${base.pathname}${properties.href}`, + ); + } + if (properties.src) { + properties.src = posixNormalize(`${base.pathname}${properties.src}`); + } + } + return tree; + }, + }; +} + +/** + * Convert a non fully qualified url into a fully qualified url, complete + * with protocol. + */ +export function* useAbsoluteUrl(path: string): Operation { + let absolute = yield* useAbsoluteUrlFactory(); + return absolute(path); +} + +export function* useAbsoluteUrlFactory(): Operation<(path: string) => string> { + let base = yield* BaseUrl.expect(); + let request = yield* CurrentRequest.expect(); + + return (path) => { + let normalizedPath = posixNormalize(path); + if (normalizedPath.startsWith("/")) { + let url = new URL(base); + url.pathname = posixNormalize(`${base.pathname}${path}`); + return url.toString(); + } else { + return new URL(path, request.url).toString(); + } + }; +} diff --git a/www/plugins/sitemap.ts b/www/plugins/sitemap.ts new file mode 100644 index 000000000..17ed9dc70 --- /dev/null +++ b/www/plugins/sitemap.ts @@ -0,0 +1,125 @@ +import type { Middleware, RevolutionPlugin } from "revolution"; +import { route as revolutionRoute, useRevolutionOptions } from "revolution"; +import type { Operation } from "effection"; +import { stringify } from "@libs/xml"; +import { compile } from "path-to-regexp"; +import { useAbsoluteUrlFactory } from "./rebase.ts"; + +export function sitemapPlugin(): RevolutionPlugin { + return { + *http(request, next) { + let options = yield* useRevolutionOptions(); + let url = new URL(request.url); + + if (url.pathname === "/sitemap.xml") { + let app = options.app ?? []; + let paths: RoutePath[] = []; + for (let middleware of app) { + let ext = middleware as SitemapExtension; + if (ext.sitemapExtension) { + paths = paths.concat(yield* ext.sitemapExtension(request)); + } + } + + let absolute = yield* useAbsoluteUrlFactory(); + + let xml = stringify({ + "@version": "1.0", + "@encoding": "UTF-8", + urlset: { + "@xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9", + url: paths.map((path) => { + let { pathname, ...entry } = path; + + return { + loc: absolute(pathname), + ...entry, + }; + }), + }, + }); + + return new Response(xml, { + status: 200, + headers: { + "Content-Type": "application/xml", + }, + }); + } + return yield* next(request); + }, + }; +} + +export interface SitemapExtension { + sitemapExtension?(request: Request): Operation; +} + +export interface RoutePath { + pathname: string; + lastmod?: string; + changefreq?: + | "always" + | "hourly" + | "daily" + | "weekly" + | "monthly" + | "yearly" + | "never"; + priority?: number; +} + +/** + * Just like a route, but generates a sitemap for all the urls + */ +export interface SitemapRoute { + /** + * The HTTP or HTML handler for this route + */ + handler: Middleware; + + /** + * Generate a list of paths for this route. It will be passed a function which + * will substitute in the parameters of the route to generate the path as a string. + * For example: + * + * ```ts + * // assuming a route pattern: "/users/:username" + * generate({ username: 'cowboyd' }) //=> "/users/cowboyd" + * ``` + * @param generate - a function to generate a single pathname + * @param request - the request for the sitemap + * @returns a list of `RoutePath` values + */ + routemap?( + generate: (params?: Record) => string, + request: Request, + ): Operation; +} + +export function route( + pattern: string, + middleware: Middleware | SitemapRoute, +): Middleware { + if (isSitemapRoute(middleware)) { + let handler = revolutionRoute(pattern, middleware.handler); + if (middleware.routemap) { + const { routemap } = middleware; + Object.defineProperty(handler, "sitemapExtension", { + value(request: Request) { + let generate = compile(pattern); + return routemap(generate, request); + }, + }); + } + return handler; + } else { + return revolutionRoute(pattern, middleware); + } +} + +function isSitemapRoute( + o: Middleware | SitemapRoute, +): o is SitemapRoute { + return !!(o as SitemapRoute).handler; +} diff --git a/www/plugins/tailwind.ts b/www/plugins/tailwind.ts new file mode 100644 index 000000000..0c3081ea2 --- /dev/null +++ b/www/plugins/tailwind.ts @@ -0,0 +1,77 @@ +import { crypto } from "@std/crypto"; +import { encodeHex } from "@std/encoding/hex"; +import { emptyDir, exists } from "@std/fs"; +import { serveFile } from "@std/http"; +import { join } from "@std/path"; +import { Operation, until } from "effection"; +import { select } from "hast-util-select"; +import type { RevolutionPlugin } from "revolution"; +import { $ } from "../context/shell.ts"; + +export interface TailwindOptions { + readonly input: string; + readonly outdir: string; +} + +export function* tailwindPlugin( + options: TailwindOptions, +): Operation { + yield* until(emptyDir(options.outdir)); + + let css = yield* compileCSS(options); + + return { + *html(request, next) { + let html = yield* next(request); + let head = select("head", html); + head?.children.push({ + type: "element", + tagName: "link", + properties: { rel: "stylesheet", href: css.href }, + children: [], + }); + return html; + }, + http(request, next) { + let url = new URL(request.url); + if (url.pathname === css.csspath) { + return until(serveFile(request, css.filepath)); + } else { + return next(request); + } + }, + }; +} + +interface CSS { + filepath: string; + csspath: string; + href: string; +} + +function* compileCSS(options: TailwindOptions): Operation { + let { input, outdir } = options; + let output = join(outdir, input); + + yield* $( + `deno run -A \ +--unstable-detect-cjs \ +npm:@tailwindcss/cli@^4.0.0 \ +--config tailwind.config.ts \ +--input ${input} \ +--output ${output}`, + ); + + if (yield* until(exists(output))) { + let content = yield* until(Deno.readFile(output)); + const buffer = yield* until(crypto.subtle.digest("SHA-256", content)); + const hash = encodeHex(buffer); + return { + filepath: output, + csspath: `/${output}`, + href: `/${output}?${hash}`, + }; + } + + throw new Error(`failed to generate ${output}`); +} diff --git a/www/resources/guides.ts b/www/resources/guides.ts new file mode 100644 index 000000000..5f614edf2 --- /dev/null +++ b/www/resources/guides.ts @@ -0,0 +1,168 @@ +import { basename } from "@std/path"; +import { + all, + createContext, + type Operation, + resource, + type Task, + until, + useScope, +} from "effection"; +import { JSXElement } from "revolution/jsx-runtime"; +import { z } from "zod"; + +import { useMarkdown } from "../hooks/use-markdown.tsx"; +import { createToc } from "../lib/toc.ts"; +import { useWorktree } from "../lib/worktrees.ts"; +import { $ } from "../context/shell.ts"; + +export interface DocModule { + default: () => JSX.Element; + frontmatter: { + id: string; + title: string; + }; +} + +export interface Guides { + all(): Operation; + get(id?: string): Operation; + first(): Operation; +} + +export interface Topic { + name: string; + items: GuidesMeta[]; +} + +export interface GuidesMeta { + id: string; + title: string; + filename: string; + topics: Topic[]; + next?: GuidesMeta; + prev?: GuidesMeta; +} + +export interface GuidesPage extends GuidesMeta { + content: JSXElement; + toc: JSXElement; + markdown: string; +} + +const Structure = z.record( + z.string(), + z.array(z.tuple([z.string(), z.string()])), +); + +export type StructureJson = z.infer; + +const GuidesContext = createContext>("guides"); + +export type GuidesOptions = { + current: string; + worktrees: string[]; +}; + +export function* initGuides(options: GuidesOptions): Operation { + const guides = new Map(); + + let path = yield* useGitRoot(); + guides.set(options.current, yield* loadGuides(path)); + + for (let series of options.worktrees) { + let path = yield* useWorktree(series); + guides.set(series, yield* loadGuides(path)); + } + + yield* GuidesContext.set(guides); +} + +export function* useGitRoot() { + let result = yield* $(`git rev-parse --show-toplevel`); + return result.stdout.trim(); +} + +export function* useGuides(series: string): Operation { + let guidesBySeries = yield* GuidesContext.expect(); + let guides = guidesBySeries.get(series); + if (!guides) { + throw new Error(`guides not found for series '${series}'`); + } + return guides; +} + +export function loadGuides(dirpath: string): Operation { + return resource(function* (provide) { + let scope = yield* useScope(); + let loaders = new Map>(); + + let structureModule = yield* until( + import(`${dirpath}/docs/structure.json`, { with: { type: "json" } }), + ); + + let structure = Structure.parse(structureModule.default); + + let entries = Object.entries(structure); + + let topics: Topic[] = []; + + for (let [name, contents] of entries) { + let topic: Topic = { name, items: [] }; + topics.push(topic); + + let current: GuidesMeta | undefined = void (0); + for (let i = 0; i < contents.length; i++) { + let prev: GuidesMeta | undefined = current; + let [filename, title] = contents[i]; + let meta: GuidesMeta = current = { + id: basename(filename, ".mdx"), + title, + filename: `docs/${filename}`, + topics, + prev, + }; + if (prev) { + prev.next = current; + } + topic.items.push(current); + + loaders.set( + meta.id, + scope.run(function* () { + let source = yield* until( + Deno.readTextFile(`${dirpath}/${meta.filename}`), + ); + + const content = yield* useMarkdown(source); + + return { + ...meta, + markdown: source, + content, + toc: createToc(content), + }; + }), + ); + } + } + + yield* provide({ + *first() { + const [[_id, task]] = loaders.entries(); + return yield* task; + }, + *all() { + return yield* all([...loaders.values()]); + }, + *get(id) { + if (id) { + let task = loaders.get(id); + if (task) { + return yield* task; + } + } + }, + }); + }); +} diff --git a/www/resources/jsr-client.ts b/www/resources/jsr-client.ts new file mode 100644 index 000000000..f7309b17a --- /dev/null +++ b/www/resources/jsr-client.ts @@ -0,0 +1,110 @@ +import { call, type Operation, resource } from "effection"; +import { z } from "zod"; + +interface GetPackageDetailsParams { + scope: string; + package: string; +} + +const PackageScore = z.object({ + hasReadme: z.boolean(), + hasReadmeExamples: z.boolean(), + allEntrypointsDocs: z.boolean(), + allFastCheck: z.boolean(), + hasProvenance: z.boolean(), + hasDescription: z.boolean(), + atLeastOneRuntimeCompatible: z.boolean(), + multipleRuntimesCompatible: z.boolean(), + percentageDocumentedSymbols: z.number().min(0).max(1), + total: z.number(), +}); + +const PackageDetails = z.object({ + scope: z.string(), + name: z.string(), + description: z.string(), + runtimeCompat: z.object({ + browser: z.boolean().optional(), + deno: z.boolean().optional(), + node: z.boolean().optional(), + bun: z.boolean().optional(), + workerd: z.boolean().optional(), + }), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + githubRepository: z.object({ + id: z.number(), + owner: z.string(), + name: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }).nullable(), + score: z.number().min(0).max(100).nullable(), +}); + +export type PackageScoreResult = z.infer; +export type PackageDetailsResult = z.infer; + +export interface JSRClient { + getPackageScore: ( + params: GetPackageDetailsParams, + ) => Operation>; + getPackageDetails: ( + params: GetPackageDetailsParams, + ) => Operation>; +} + +export function createJSRClient(token: string): Operation { + return resource(function* (provide) { + yield* provide({ + *getPackageScore(params) { + const response = yield* call(() => + fetch( + `https://api.jsr.io/scopes/${params.scope}/packages/${params.package}/score`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + ); + + if (response.ok) { + const json = yield* call(() => response.json()); + return yield* call(() => PackageScore.safeParseAsync(json)); + } + + throw new Error( + `Could not get package score for @${params.scope}/${params.package}`, + { + cause: `${response.status}: ${response.statusText}`, + }, + ); + }, + *getPackageDetails(params) { + const response = yield* call(() => + fetch( + `https://api.jsr.io/scopes/${params.scope}/packages/${params.package}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + ); + + if (response.ok) { + const json = yield* call(() => response.json()); + return yield* call(() => PackageDetails.safeParseAsync(json)); + } + + throw new Error( + `Could not get package details for @${params.scope}/${params.package}`, + { + cause: `${response.status}: ${response.statusText}`, + }, + ); + }, + }); + }); +} diff --git a/www/routes/api-index-route.tsx b/www/routes/api-index-route.tsx new file mode 100644 index 000000000..0fdcd9110 --- /dev/null +++ b/www/routes/api-index-route.tsx @@ -0,0 +1,106 @@ +import { type JSXElement } from "revolution"; + +import { Icon } from "../components/type/icon.tsx"; +import { DocPage } from "../hooks/use-deno-doc.tsx"; +import { ResolveLinkFunction } from "../hooks/use-markdown.tsx"; +import { usePackage } from "../lib/package.ts"; +import { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createChildURL } from "./links-resolvers.ts"; + +export function apiIndexRoute( + { search }: { search: boolean }, +): SitemapRoute { + return { + *routemap(gen) { + return [{ pathname: gen() }]; + }, + handler: function* () { + let v3 = yield* usePackage({ + type: "worktree", + series: "v3", + }); + + let v4 = yield* usePackage({ + type: "worktree", + series: "v4", + }); + + let docs = { + v3: yield* v3.docs(), + v4: yield* v4.docs(), + }; + + const AppHtml = yield* useAppHtml({ + title: `API Reference | Effection`, + description: `API Reference for Effection`, + }); + + return ( + +
    +

    API Reference

    +
    +

    + {v4.version} + + + +

    +
      + {yield* listPages({ + pages: docs.v4["."], + linkResolver: createChildURL("v4"), + })} +
    +
    +
    +
    +

    + {v3.version} + + + +

    +
      + {yield* listPages({ + pages: docs.v3["."], + linkResolver: createChildURL("v3"), + })} +
    +
    +
    + + ); + }, + }; +} + +function* listPages({ + pages, + linkResolver, +}: { + pages: DocPage[]; + linkResolver: ResolveLinkFunction; +}) { + const elements = []; + + for (const page of pages.sort((a, b) => a.name.localeCompare(b.name))) { + const link = yield* linkResolver(page.name); + elements.push( +
  • + + + {page.name} + +
  • , + ); + } + return <>{elements}; +} diff --git a/www/routes/api-reference-route.tsx b/www/routes/api-reference-route.tsx new file mode 100644 index 000000000..8fc10aa3a --- /dev/null +++ b/www/routes/api-reference-route.tsx @@ -0,0 +1,67 @@ +import { type JSXElement, useParams } from "revolution"; + +import { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; + +import { ApiPage } from "../components/api/api-page.tsx"; +import { usePackage } from "../lib/package.ts"; +import { createSibling } from "./links-resolvers.ts"; + +export function apiReferenceRoute(series: "v3" | "v4", { + search, +}: { + search: boolean; +}): SitemapRoute { + return { + *routemap(generate) { + let pkg = yield* usePackage({ + type: "worktree", + series, + }); + + let docs = yield* pkg.docs(); + + return docs["."] + .map((node) => node.name) + .flatMap((symbol) => { + return [ + { + pathname: generate({ symbol }), + }, + ]; + }); + }, + handler: function* () { + let { symbol } = yield* useParams<{ symbol: string }>(); + + let pkg = yield* usePackage({ + type: "worktree", + series, + }); + + let docs = yield* pkg.docs(); + + const pages = docs["."]; + + const page = pages.find((node) => node.name === symbol); + + if (!page) throw new Error(`Could not find a doc page for ${symbol}`); + + const AppHtml = yield* useAppHtml({ + title: `${symbol} | API Reference | Effection`, + description: page.description, + }); + + return ( + + {yield* ApiPage({ + pages, + current: symbol, + pkg, + externalLinkResolver: createSibling, + })} + + ); + }, + }; +} diff --git a/www/routes/app.html.tsx b/www/routes/app.html.tsx new file mode 100644 index 000000000..8314890ce --- /dev/null +++ b/www/routes/app.html.tsx @@ -0,0 +1,83 @@ +import type { Operation } from "effection"; +import type { JSXChild } from "revolution"; + +import { Footer } from "../components/footer.tsx"; +import { Header, type HeaderProps } from "../components/header.tsx"; +import { useAbsoluteUrl } from "../plugins/rebase.ts"; +import { JSXElement } from "revolution/jsx-runtime"; + +export type Options = { + title: string; + description: string; + head?: JSXElement; +} & HeaderProps; + +export interface AppHtmlProps { + children: JSXChild; + search?: boolean; +} + +export function* useAppHtml({ + title, + description, + hasLeftSidebar, + head, +}: Options): Operation<({ children, search }: AppHtmlProps) => JSX.Element> { + let twitterImageURL = yield* useAbsoluteUrl( + "/assets/images/meta-effection.png", + ); + let homeURL = yield* useAbsoluteUrl("/"); + + const header = yield* Header({ hasLeftSidebar }); + + return ({ children, search }) => ( + + + + {title} + + + + + + + + + + + + + + + + {/* @ts-expect-error Custom element is-land from @11ty/is-land */} + + + {/* @ts-expect-error Custom element is-land from @11ty/is-land */} + + + + ); + }, + }; +} diff --git a/www/routes/x-index-route.tsx b/www/routes/x-index-route.tsx new file mode 100644 index 000000000..eb1721fcc --- /dev/null +++ b/www/routes/x-index-route.tsx @@ -0,0 +1,123 @@ +import { all } from "effection"; +import type { JSXElement } from "revolution"; +import { GithubPill } from "../components/package/source-link.tsx"; +import { useWorkspace } from "../lib/workspace.ts"; +import type { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createChildURL, createSibling } from "./links-resolvers.ts"; +import { softRedirect } from "./redirect.tsx"; + +export function xIndexRedirect(): SitemapRoute { + return { + *routemap(pathname) { + return [{ pathname: pathname() }]; + }, + *handler(req) { + return yield* softRedirect(req, yield* createSibling("x")); + }, + }; +} + +export function xIndexRoute({ + search, +}: { + search: boolean; +}): SitemapRoute { + return { + *routemap(gen) { + return [{ pathname: gen() }]; + }, + *handler() { + let workspace = yield* useWorkspace("thefrontside/effectionx"); + + const AppHTML = yield* useAppHtml({ + title: "Extensions | Effection", + description: + "List of community contributed modules that represent emerging consensus on how to do common JavaScript tasks with Effection.", + }); + + const makeChildUrl = createChildURL(); + + return ( + +
    +
    +

    + Effection Extensions +

    + {yield* GithubPill({ + url: workspace.url, + text: workspace.nameWithOwner, + class: + "flex flex-row w-fit h-10 items-center rounded-full bg-gray-200 dark:bg-gray-800 px-2 py-1 text-gray-900 dark:text-gray-100", + })} +
    +

    + A collection of reusable, community-created extensions - ranging + from small packages to complete frameworks - that show the best + practices for handling common JavaScript tasks with Effection. +

    +
    +

    + Frameworks +

    + +
    +
    +

    + Packages +

    +
      + {yield* all( + workspace.packages.map(function* (pkg) { + const [details] = yield* pkg.jsrPackageDetails(); + + let title; + let description; + if (details && details.success) { + title = `@${details.data.scope}/${details.data.name}`; + description = details.data.description; + } else { + title = pkg.workspacePath; + description = yield* pkg.description(); + } + + return ( +
    • + + + {title} + + + {description} + + +
    • + ); + }), + )} +
    +
    +
    +
    + ); + }, + }; +} diff --git a/www/routes/x-package-route.tsx b/www/routes/x-package-route.tsx new file mode 100644 index 000000000..8ef5b54c4 --- /dev/null +++ b/www/routes/x-package-route.tsx @@ -0,0 +1,241 @@ +import { call } from "effection"; +import { shiftHeading } from "hast-util-shift-heading"; +import type { Nodes } from "hast/"; +import { + type JSXElement, // @ts-types="revolution" + respondNotFound, + useParams, +} from "revolution"; + +import { select } from "hast-util-select"; +import { ApiBody } from "../components/api/api-page.tsx"; +import { PackageExports } from "../components/package/exports.tsx"; +import { PackageHeader } from "../components/package/header.tsx"; +import { ScoreCard } from "../components/score-card.tsx"; +import { Icon } from "../components/type/icon.tsx"; +import { DocPageContext } from "../context/doc-page.ts"; +import { useMarkdown } from "../hooks/use-markdown.tsx"; +import { createToc } from "../lib/toc.ts"; +import { useWorkspace } from "../lib/workspace.ts"; +import type { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createSibling } from "./links-resolvers.ts"; +import { softRedirect } from "./redirect.tsx"; + +interface XPackageRouteParams { + search: boolean; +} + +function routemap(): SitemapRoute["routemap"] { + return function* (pathname) { + let paths: RoutePath[] = []; + + let workspace = yield* useWorkspace("thefrontside/effectionx"); + + for (let workspacePath of workspace.root.workspaces) { + paths.push({ + pathname: pathname({ + workspacePath: workspacePath.replace(/^\.\//, ""), + }), + }); + } + + return paths; + }; +} + +export function xPackageRedirect(): SitemapRoute { + return { + routemap: routemap(), + *handler(req) { + const params = yield* useParams<{ workspacePath: string }>(); + return yield* softRedirect( + req, + yield* createSibling(params.workspacePath), + ); + }, + }; +} + +export function xPackageRoute({ + search, +}: XPackageRouteParams): SitemapRoute { + return { + routemap: routemap(), + *handler() { + let params = yield* useParams<{ workspacePath: string }>(); + + let workspace = yield* useWorkspace("thefrontside/effectionx"); + + let pkg = workspace.packages.find((pkg) => + pkg.workspacePath.replace("./", "") === params.workspacePath + ); + + if (!pkg) { + return yield* respondNotFound(); + } + + try { + const docs = yield* pkg.docs(); + + const AppHTML = yield* useAppHtml({ + title: `${pkg.name} | Extensions | Effection`, + description: yield* pkg.description(), + }); + + const linkResolver = function* ( + symbol: string, + connector?: string, + method?: string, + ) { + const internal = `#${symbol}_${method}`; + if (connector === "_") { + return internal; + } + const page = docs["."].find( + (page) => page.name === symbol && page.kind !== "import", + ); + + if (page) { + // get internal link + return `[${symbol}](#${page.kind}_${page.name})`; + } + + return symbol; + }; + + const apiReference = []; + + const entrypoints = Object.entries(docs); + + for (const [entrypoint, pages] of entrypoints) { + const sections = []; + for (const page of pages) { + const content = yield* call(function* () { + yield* DocPageContext.set(page); + return yield* ApiBody({ page, linkResolver }); + }); + sections.push(content); + } + if (entrypoint.length === 1 && entrypoint === ".") { + apiReference.push( +
    + <>{sections} +
    , + ); + } else if (pages.length > 0) { + apiReference.push( +
    +

    {entrypoint}

    + <>{sections} +
    , + ); + } + } + + apiReference.forEach((section) => shiftHeading(section, 1)); + + const content = ( + <> + {yield* useMarkdown(yield* pkg.readme(), { linkResolver })} +

    API Reference

    + <>{apiReference} + + ); + + const toc = createToc(content, { + headings: ["h2", "h3"], + cssClasses: { + toc: + "hidden text-sm font-light tracking-wide leading-loose lg:block relative", + link: "flex flex-row items-center", + }, + customizeTOCItem(item, heading) { + heading.properties.class = [ + heading.properties.class, + `group scroll-mt-[100px]`, + ] + .filter(Boolean) + .join(""); + + const ol = select("ol.toc-level-2, ol.toc-level-3", item as Nodes); + if (ol) { + ol.properties.className = `${ol.properties.className} ml-6`; + } + if ( + heading.properties["data-kind"] && + heading.properties["data-name"] + ) { + item.properties.className += " mb-1"; + const a = select("a", item as Nodes); + if (a) { + // deno-lint-ignore no-explicit-any + (a as any).children = [ + , + + {heading.properties["data-name"]} + , + ]; + } + } else { + const a = select("a", item as Nodes); + if (a) { + a.properties.className = + `hover:underline hover:underline-offset-2`; + } + } + return item; + }, + }); + + return ( + + <> +
    +
    + {yield* PackageHeader(pkg)} +
    +
    + {yield* PackageExports({ + packageName: pkg.name, + docs, + linkResolver, + })} +
    + {content} +
    +
    + +
    + +
    + ); + } catch (e) { + console.error(e); + const AppHTML = yield* useAppHtml({ + title: `${params.workspacePath} not found`, + description: `Failed to load ${params.workspacePath} due to error.`, + }); + return ( + +

    Failed to load {params.workspacePath} due to error.

    +
    + ); + } + }, + }; +} diff --git a/www/tailwind.config.ts b/www/tailwind.config.ts new file mode 100644 index 000000000..a7d299e0a --- /dev/null +++ b/www/tailwind.config.ts @@ -0,0 +1,26 @@ +import { Config } from "tailwindcss"; + +export default { + content: [ + "./src/**/*.{js,ts,jsx,tsx,mdx}", + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + fontFamily: { + sans: ["Proxima Nova", "proxima-nova", "sans-serif"], + inter: ["Inter", "inter", "san-serif"], + }, + extend: { + colors: { + "blue-primary": "#14315D", + "blue-secondary": "#26ABE8", + "pink-secondary": "#F74D7B", + "typescript-blue": "#3178c6", + }, + screens: { + sm: { max: "540px" }, + }, + }, + }, +} satisfies Config; diff --git a/www/testing.ts b/www/testing.ts new file mode 100644 index 000000000..02d16a0d0 --- /dev/null +++ b/www/testing.ts @@ -0,0 +1,62 @@ +import type { Operation } from "effection"; +import { + afterAll as $afterAll, + describe as $describe, + it as $it, +} from "@std/testing/bdd"; +import { createTestAdapter, type TestAdapter } from "./testing/adapter.ts"; + +let current: TestAdapter | undefined; + +export function describe(name: string, body: () => void) { + const isTop = !current; + const original = current; + try { + const child = current = createTestAdapter({ name, parent: original }); + if (isTop) { + // + } + + $describe(name, () => { + $afterAll(() => child.destroy()); + body(); + }); + } finally { + current = original; + } +} + +describe.skip = $describe.skip; +describe.only = $describe.only; + +export function beforeEach(body: () => Operation) { + current?.addSetup(body); +} + +export function it(desc: string, body?: () => Operation): void { + const adapter = current!; + if (!body) { + return $it.skip(desc, () => {}); + } + $it(desc, async () => { + const result = await adapter.runTest(body); + if (!result.ok) { + throw result.error; + } + }); +} + +it.skip = (...args: Parameters): ReturnType => { + const [desc] = args; + $it.skip(desc, () => {}); +}; + +it.only = (desc: string, body: () => Operation): void => { + const adapter = current!; + $it.only(desc, async () => { + const result = await adapter.runTest(body); + if (!result.ok) { + throw result.error; + } + }); +}; diff --git a/www/testing/adapter.ts b/www/testing/adapter.ts new file mode 100644 index 000000000..29c024d3f --- /dev/null +++ b/www/testing/adapter.ts @@ -0,0 +1,134 @@ +import type { Future, Operation, Result, Scope } from "effection"; +import { createScope, Err, Ok } from "effection"; + +export interface TestOperation { + (): Operation; +} + +export interface TestAdapter { + /** + * The parent of this adapter. All of the setup from this adapter will be + * run in addition to the setup of this adapter during `runTest()` + */ + readonly parent?: TestAdapter; + + /** + * The name of this adapter which is mostly useful for debugging purposes + */ + readonly name: string; + + /** + * A qualified name that contains not only the name of this adapter, but of all its + * ancestors. E.g. `All Tests > File System > write` + */ + readonly fullname: string; + + /** + * Every test adapter has its own Effection `Scope` which holds the resources necessary + * to run this test. + */ + readonly scope: Scope; + + /** + * A list of this test adapter and every adapter that it descends from. + */ + readonly lineage: Array; + + /** + * The setup operations that will be run by this test adapter. It only includes those + * setups that are associated with this adapter, not those of its ancestors. + */ + readonly setups: TestOperation[]; + + /** + * Add a setup operation to every test that is part of this adapter. In BDD integrations, + * this is usually called by `beforEach()` + */ + addSetup(op: TestOperation): void; + + /** + * Actually run a test. This evaluates all setup operations, and then after those have completed + * it runs the body of the test itself. + */ + runTest(body: TestOperation): Future>; + + /** + * Teardown this test adapter and all of the task and resources that are running inside it. + * This basically destroys the Effection `Scope` associated with this adapter. + */ + destroy(): Future; +} + +export interface TestAdapterOptions { + /** + * The name of this test adapter which is handy for debugging. + * Usually, you'll give this the same name as the current test + * context. For example, when integrating with BDD, this would be + * the same as + */ + name?: string; + /** + * The parent test adapter. All of the setup from this adapter will be + * run in addition to the setup of this adapter during `runTest()` + */ + parent?: TestAdapter; +} + +const anonymousNames: Iterator = (function* () { + let count = 1; + while (true) { + yield `anonymous test adapter ${count++}`; + } +})(); + +/** + * Create a new test adapter with the given options. + */ +export function createTestAdapter( + options: TestAdapterOptions = {}, +): TestAdapter { + const setups: TestOperation[] = []; + const { parent, name = anonymousNames.next().value } = options; + + const [scope, destroy] = createScope(parent?.scope); + + const adapter: TestAdapter = { + parent, + name, + scope, + setups, + get lineage() { + const lineage = [adapter]; + for (let current = parent; current; current = current.parent) { + lineage.unshift(current); + } + return lineage; + }, + get fullname() { + return adapter.lineage.map((adapter) => adapter.name).join(" > "); + }, + addSetup(op) { + setups.push(op); + }, + runTest(op) { + return scope.run(function* () { + const allSetups = adapter.lineage.reduce( + (all, adapter) => all.concat(adapter.setups), + [] as TestOperation[], + ); + try { + for (const setup of allSetups) { + yield* setup(); + } + yield* op(); + return Ok(void 0); + } catch (error) { + return Err(error as Error); + } + }); + }, + destroy, + }; + + return adapter; +} diff --git a/www/testing/helpers.ts b/www/testing/helpers.ts new file mode 100644 index 000000000..7d96ff6dc --- /dev/null +++ b/www/testing/helpers.ts @@ -0,0 +1,53 @@ +import { type Operation, until } from "effection"; +import { $ } from "../context/shell.ts"; +import * as fs from "@std/fs"; + +export function ensureDir(dir: string | URL) { + return until(fs.ensureDir(dir)); +} + +export function writeTextFile( + path: string | URL, + data: string | ReadableStream, + options?: Deno.WriteFileOptions, +) { + return until(Deno.writeTextFile(path, data, options)); +} + +export interface GitCommit { + sha: string; + message: string; + tags: string[]; +} + +/** + * Gets git commit history from current directory in chronological order with detailed info + * @returns Array of commits with sha, message, and tags in chronological order (oldest first) + */ +export function* getGitHistory(): Operation { + // Get commit history with hash and message + const historyResult = yield* $(`git log --format="%H|%s" --reverse`); + + const lines = historyResult.stdout.split("\n").filter((line) => + line.length > 0 + ); + const commits: GitCommit[] = []; + + for (const line of lines) { + const [sha, message] = line.split("|"); + + // Get tags for this commit using $ shell utility + try { + const tagsResult = yield* $(`git tag --points-at ${sha}`); + const tags = tagsResult.stdout.split("\n").filter((tag) => + tag.length > 0 + ); + commits.push({ sha, message, tags }); + } catch { + // No tags for this commit + commits.push({ sha, message, tags: [] }); + } + } + + return commits; +} diff --git a/www/testing/logging.ts b/www/testing/logging.ts new file mode 100644 index 000000000..3a3bf9a22 --- /dev/null +++ b/www/testing/logging.ts @@ -0,0 +1,62 @@ +import { createQueue, type Queue, resource } from "effection"; +import { loggerApi } from "../context/logging.ts"; + +const levels = ["info", "warn", "debug", "error"] as const; + +type LogEvent = + | { type: "info"; args: unknown[] } + | { type: "warn"; args: unknown[] } + | { type: "debug"; args: unknown[] } + | { type: "error"; args: unknown[] }; + +export function* setupLogging(level: (typeof levels)[number] | false) { + const queue = yield* resource>(function* (provide) { + const queue = createQueue(); + try { + yield* provide(queue); + } finally { + queue.close(); + } + }); + + if (level === false) { + yield* loggerApi.around({ + *info() {}, + *debug() {}, + *warn() {}, + *error() {}, + }); + return; + } + yield* loggerApi.around({ + *info(args, next) { + if (level === "info") { + queue.add({ type: "info", args }); + return yield* next(...args); + } + }, + *warn(args, next) { + if (level === "info" || level === "warn") { + queue.add({ type: "warn", args }); + return yield* next(...args); + } + }, + *debug(args, next) { + if (level === "info" || level === "warn" || level === "debug") { + queue.add({ type: "debug", args }); + return yield* next(...args); + } + }, + *error(args, next) { + if ( + level === "info" || level === "warn" || level === "debug" || + level === "error" + ) { + queue.add({ type: "error", args }); + return yield* next(...args); + } + }, + }); + + return queue; +} diff --git a/www/testing/temp-dir.ts b/www/testing/temp-dir.ts new file mode 100644 index 000000000..7138843f7 --- /dev/null +++ b/www/testing/temp-dir.ts @@ -0,0 +1,71 @@ +import { type Operation, resource, until } from "effection"; +import { ensureDir, ensureFile } from "@std/fs"; +import { join } from "@std/path"; + +function* writeFiles( + dir: string, + files: Record, +): Operation { + for (const [path, content] of Object.entries(files)) { + yield* until(ensureFile(join(dir, path))); + yield* until(Deno.writeTextFile(join(dir, path), content)); + } +} + +export interface TempDir { + withFiles(files: Record): Operation; + withWorkspace( + workspace: string, + files: Record, + ): Operation; + path: string; +} + +interface CreateTempDirParams { + autoClean?: boolean; + baseDir?: string; +} + +export function createTempDir( + params?: CreateTempDirParams, +): Operation { + return resource(function* (provide) { + const { + baseDir, + autoClean, + } = params || {}; + let dir: string; + + if (baseDir) { + // Create directory in specified base directory + yield* until(ensureDir(baseDir)); + const timestamp = Date.now().toString(36); + const randomSuffix = Math.random().toString(36).substring(2, 8); + const dirName = `${timestamp}-${randomSuffix}`; + dir = join(baseDir, dirName); + yield* until(ensureDir(dir)); + } else { + // Fall back to system temp directory + dir = yield* until(Deno.makeTempDir()); + } + + try { + yield* provide({ + get path() { + return dir; + }, + *withFiles(files: Record) { + yield* writeFiles(dir, files); + }, + *withWorkspace(workspace: string, files: Record) { + yield* writeFiles(join(dir, workspace), files); + }, + }); + } finally { + // Only remove if we created it (not if it's in a managed base directory) + if (autoClean) { + yield* until(Deno.remove(dir, { recursive: true })); + } + } + }); +} From c3222c3f6005530c8409d9d6fd1ad191189f4b19 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 22 Dec 2025 21:06:20 -0500 Subject: [PATCH 084/119] Fix bunch of little things (#1051) * index redirect should go to the v4 route - Changed header link to point to v4 - Changed docs/ direct to go to v4 * v3 route should have a banner indicating that this is the guide for the old version with link to new version * navigation toggle between versions of docs * Removed Deno Deploy detection, no longer necessary * navigation tab for Api docs to go between v3 and v4. - Added config to store version * highlight on hover rather than appear/disappear View Code link * Moved link resolvers to libs * View code url should point to GitHub * Fix: deep links to api version cause the api text to hide behind header. * Try running www on macos * Use Mac OS binaries * Use Deno 2.6.1 in CI to prevent https://github.com/denoland/deno/issues/31665 * Fixed bdd not rendering * Remove @ts-expect-error --- .github/workflows/www.yaml | 14 +-- lib/main.ts | 3 +- www/components/api/api-page.tsx | 63 +++++++---- www/components/header.tsx | 2 +- www/components/type/jsx.tsx | 10 ++ www/components/type/markdown.tsx | 1 - www/context/config.ts | 19 ++++ www/context/doc-page.ts | 4 +- www/deno-deploy-patch.ts | 26 ----- www/hooks/use-deno-doc.tsx | 44 +++++++- www/hooks/use-markdown.tsx | 2 +- www/{routes => lib}/links-resolvers.ts | 0 www/lib/package.ts | 39 +++++-- www/lib/workspace.ts | 3 +- www/main.tsx | 27 +++-- www/routes/api-index-route.tsx | 8 +- www/routes/api-reference-route.tsx | 5 +- www/routes/guides-route.tsx | 141 ++++++++++++++----------- www/routes/redirect-docs-route.tsx | 2 +- www/routes/x-index-route.tsx | 2 +- www/routes/x-package-route.tsx | 4 +- 21 files changed, 261 insertions(+), 158 deletions(-) create mode 100644 www/context/config.ts delete mode 100644 www/deno-deploy-patch.ts rename www/{routes => lib}/links-resolvers.ts (100%) diff --git a/.github/workflows/www.yaml b/.github/workflows/www.yaml index 76da0cf10..92e78aa41 100644 --- a/.github/workflows/www.yaml +++ b/.github/workflows/www.yaml @@ -10,7 +10,7 @@ on: jobs: www: - runs-on: ubuntu-latest + runs-on: macos-latest timeout-minutes: 15 permissions: @@ -26,7 +26,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v2 with: - deno-version: v2.x + deno-version: v2.6.1 - name: Serve Website run: | @@ -43,14 +43,14 @@ jobs: - name: Download Staticalize run: | - wget https://github.com/thefrontside/staticalize/releases/download/v0.2.2/staticalize-linux.tar.gz \ - -O /tmp/staticalize-linux.tar.gz - tar -xzf /tmp/staticalize-linux.tar.gz -C /usr/local/bin - chmod +x /usr/local/bin/staticalize-linux + wget https://github.com/thefrontside/staticalize/releases/download/v0.2.2/staticalize-macos.tar.gz \ + -O /tmp/staticalize-macos.tar.gz + tar -xzf /tmp/staticalize-macos.tar.gz -C /usr/local/bin + chmod +x /usr/local/bin/staticalize-macos - name: Staticalize run: | - staticalize-linux \ + staticalize-macos \ --site=http://127.0.0.1:8000 \ --output=www/built \ --base=https://effection-www.deno.dev/ diff --git a/lib/main.ts b/lib/main.ts index 0bc54be41..065d39caa 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -180,8 +180,7 @@ function* withHost(op: HostOperation): Operation { // @see https://github.com/iliakan/detect-node/blob/master/index.js } else if ( Object.prototype.toString.call( - // @ts-expect-error we are just detecting the possibility, so type strictness not required - typeof globalThis.process !== "undefined" ? globalThis.process : 0, + typeof global.process !== "undefined" ? global.process : 0, ) === "[object process]" ) { return yield* op.node(); diff --git a/www/components/api/api-page.tsx b/www/components/api/api-page.tsx index a8bfc9495..1327ed82f 100644 --- a/www/components/api/api-page.tsx +++ b/www/components/api/api-page.tsx @@ -1,10 +1,11 @@ +import { all } from "effection"; import type { JSXElement } from "revolution"; -import { DocPage } from "../../hooks/use-deno-doc.tsx"; +import { useConfig } from "../../context/config.ts"; +import { LocalDocPage } from "../../hooks/use-deno-doc.tsx"; import { ResolveLinkFunction, useMarkdown } from "../../hooks/use-markdown.tsx"; -import { Package } from "../../lib/package.ts"; +import { Package, usePackage } from "../../lib/package.ts"; import { major } from "../../lib/semver.ts"; -import { createSibling } from "../../routes/links-resolvers.ts"; -import { IconExternal } from "../icons/external.tsx"; +import { createRootUrl, createSibling } from "../../lib/links-resolvers.ts"; import { SourceCodeIcon } from "../icons/source-code.tsx"; import { GithubPill } from "../package/source-link.tsx"; import { Icon } from "../type/icon.tsx"; @@ -19,7 +20,7 @@ export function* ApiPage({ banner, }: { current: string; - pages: DocPage[]; + pages: LocalDocPage[]; pkg: Package; banner?: JSXElement; externalLinkResolver: ResolveLinkFunction; @@ -61,6 +62,33 @@ export function* ApiPage({ ), linkResolver: createSibling, + versionToggle: yield* (function* () { + const { series: SERIES } = yield* useConfig(); + const currentSeries = `v${major(pkg.version)}`; + + const links = yield* all( + SERIES.map(function* (s) { + const seriesPkg = yield* usePackage({ + type: "worktree", + series: s, + }); + const seriesDocs = yield* seriesPkg.docs(); + const hasSymbol = seriesDocs["."].some((node) => node.name === current); + + if (!hasSymbol) return null; + + return ( + + {seriesPkg.version} + + ); + }), + ); + return {...links.filter((link): link is JSXElement => link !== null)}; + })(), })} ); @@ -70,7 +98,7 @@ export function* ApiBody({ page, linkResolver, }: { - page: DocPage; + page: LocalDocPage; linkResolver: ResolveLinkFunction; }) { const elements: JSXElement[] = []; @@ -89,8 +117,8 @@ export function* ApiBody({ {yield* Type({ node: section.node })} @@ -115,12 +143,14 @@ export function* ApiReference({ current, pages, linkResolver, + versionToggle }: { pkg: Package; content: JSXElement; current: string; - pages: DocPage[]; + pages: LocalDocPage[]; linkResolver: ResolveLinkFunction; + versionToggle: JSXElement; }) { return (
    @@ -128,16 +158,7 @@ export function* ApiReference({ @@ -153,7 +174,7 @@ export function* ApiReference({ ); } -export function* SymbolHeader({ page, pkg }: { page: DocPage; pkg: Package }) { +export function* SymbolHeader({ page, pkg }: { page: LocalDocPage; pkg: Package }) { return (

    @@ -178,7 +199,7 @@ function* Menu({ linkResolver, }: { current: string; - pages: DocPage[]; + pages: LocalDocPage[]; linkResolver: ResolveLinkFunction; }) { const elements = []; diff --git a/www/components/header.tsx b/www/components/header.tsx index d4c3a2343..7ebfe0c4c 100644 --- a/www/components/header.tsx +++ b/www/components/header.tsx @@ -32,7 +32,7 @@ export function* Header(props?: HeaderProps) {
  • Guides diff --git a/www/components/type/jsx.tsx b/www/components/type/jsx.tsx index 2f5cff5a1..6f388503f 100644 --- a/www/components/type/jsx.tsx +++ b/www/components/type/jsx.tsx @@ -168,6 +168,16 @@ function TSParam({ param }: { param: ParamDef }) { ); } + case "assign": { + return ( + <> + + {" = "} + {param.tsType ? : <>} + {param.right === "[UNSUPPORTED]" ? "{}" : <> } + + ) + } default: console.log(" unimplemented:", param); } diff --git a/www/components/type/markdown.tsx b/www/components/type/markdown.tsx index 3b1dda457..7fa0cd50f 100644 --- a/www/components/type/markdown.tsx +++ b/www/components/type/markdown.tsx @@ -380,7 +380,6 @@ function Param(paramDef: ParamDef): string { paramDef.tsType ? TypeDef(paramDef.tsType) : "" }`; } - case "assign": case "array": case "object": console.log("Param: unimplemented", paramDef); diff --git a/www/context/config.ts b/www/context/config.ts new file mode 100644 index 000000000..64c90c963 --- /dev/null +++ b/www/context/config.ts @@ -0,0 +1,19 @@ +import { createContext, type Operation } from "effection"; + +export interface SiteConfig { + series: T[]; + current: NoInfer; +} + +const ConfigContext = createContext("site-config", { + series: ["v3", "v4"], + current: "v4", +}); + +export function* initConfig(config: SiteConfig): Operation { + yield* ConfigContext.set(config); +} + +export function* useConfig(): Operation { + return yield* ConfigContext.expect(); +} diff --git a/www/context/doc-page.ts b/www/context/doc-page.ts index bffa404ae..6282ba7e8 100644 --- a/www/context/doc-page.ts +++ b/www/context/doc-page.ts @@ -1,4 +1,4 @@ import { createContext } from "effection"; -import type { DocPage } from "../hooks/use-deno-doc.tsx"; +import type { LocalDocPage } from "../hooks/use-deno-doc.tsx"; -export const DocPageContext = createContext("doc-page"); +export const DocPageContext = createContext("doc-page"); diff --git a/www/deno-deploy-patch.ts b/www/deno-deploy-patch.ts deleted file mode 100644 index 457411e89..000000000 --- a/www/deno-deploy-patch.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** see https://github.com/denoland/deploy_feedback/issues/527#issuecomment-2510631720 */ -export function patchDenoPermissionsQuerySync() { - const permissions = { - run: "denied", - read: "granted", - write: "denied", - net: "granted", - env: "granted", - sys: "denied", - ffi: "denied", - } as const; - - Deno.permissions.querySync ??= ({ name }) => { - return { - // @ts-expect-error deno-ts(7053) - state: permissions[name], - onchange: null, - partial: false, - addEventListener() {}, - removeEventListener() {}, - dispatchEvent() { - return false; - }, - }; - }; -} diff --git a/www/hooks/use-deno-doc.tsx b/www/hooks/use-deno-doc.tsx index f713a22d5..69d938408 100644 --- a/www/hooks/use-deno-doc.tsx +++ b/www/hooks/use-deno-doc.tsx @@ -4,15 +4,22 @@ import { type DocNode, type DocOptions, LoadResponse, + Location } from "@deno/doc"; import { call, type Operation, until, useScope } from "effection"; import { createGraph } from "@deno/graph"; +import { regex } from "arktype"; import { exportHash, extract } from "../components/type/markdown.tsx"; import { operations } from "../context/fetch.ts"; import { DenoJsonSchema } from "../lib/deno-json.ts"; import { useDescription } from "./use-description-parse.tsx"; +// Matches npm/jsr specifiers like @std/testing/bdd or lodash/fp +export const npmSpecifierPattern = regex( + "^(?:(?@[^/]+)/)?(?[^/]+)(?/.*)?$" +); + export type { DocNode }; export function* useDenoDoc( @@ -67,6 +74,16 @@ export function* useDocPages(specifier: string): Operation { resolved = new URL(specifier, referrer).toString(); } else if (specifier.startsWith("node:")) { resolved = `npm:@types/node@^22.13.5`; + } else { + const match = npmSpecifierPattern.exec(specifier); + if (match) { + const { scope, package: pkg, subpath } = match.groups; + const baseKey = scope ? `${scope}/${pkg}` : pkg; + if (baseKey in imports) { + const baseUrl = imports[baseKey]; + resolved = subpath ? `${baseUrl}${subpath}` : baseUrl; + } + } } return resolved; } @@ -169,7 +186,7 @@ function docLoader( }; } - if (url?.host === "github.com") { + if (url?.host && ['github.com', 'jsr.io'].includes(url.host)) { const response = yield* operations.fetch(specifier); const content = yield* until(response.text()); if (response.ok) { @@ -183,9 +200,7 @@ function docLoader( cause: response, }); } - } - - if (url?.host === "jsr.io") { + } else { console.log(`Ignoring ${url} while reading docs`); } }; @@ -281,3 +296,24 @@ function* extractImports( return imports; } + +/** + * LocalDocsPages are DocNodes that are stored locally + * but they represent symbols hosted on GitHub. They + * have LocalDocNode locations that include URLs to GitHub. + */ +export type LocalDocsPages = Record; + +export type LocalDocPage = DocPage & { sections: LocalDocPageSection[] } + +export type LocalDocPageSection = DocPageSection & { + node: LocalDocNode +} + +export type LocalDocNode = DocNode & { + location: LocalLocation +} + +export type LocalLocation = Location & { + url: URL +} \ No newline at end of file diff --git a/www/hooks/use-markdown.tsx b/www/hooks/use-markdown.tsx index f71bbd6df..9523d294e 100644 --- a/www/hooks/use-markdown.tsx +++ b/www/hooks/use-markdown.tsx @@ -79,7 +79,7 @@ export function* useMarkdown( rehypeAddClasses, { "h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]": - "group scroll-mt-[100px]", + "group scroll-mt-[100px] grow", pre: "grid", }, ], diff --git a/www/routes/links-resolvers.ts b/www/lib/links-resolvers.ts similarity index 100% rename from www/routes/links-resolvers.ts rename to www/lib/links-resolvers.ts diff --git a/www/lib/package.ts b/www/lib/package.ts index 2242eed8d..64ed80e2e 100644 --- a/www/lib/package.ts +++ b/www/lib/package.ts @@ -1,5 +1,12 @@ import { all, Operation, until } from "effection"; -import { DocsPages, useDocPages } from "../hooks/use-deno-doc.tsx"; +import { fileURLToPath } from "node:url"; +import { relative } from "node:path"; + +import { + DocsPages, + LocalDocsPages, + useDocPages, +} from "../hooks/use-deno-doc.tsx"; import { createRepo, Ref } from "./repo.ts"; import { extractVersion, findLatestSemverTag } from "./semver.ts"; import { useWorktree } from "./worktrees.ts"; @@ -12,10 +19,11 @@ import { useJSRClient } from "../context/jsr.ts"; import z from "zod"; import { useMDX } from "../hooks/use-mdx.tsx"; import { useDescription, useTitle } from "../hooks/use-description-parse.tsx"; +import { SiteConfig } from "../context/config.ts"; export type WorkTreePackageOptions = { type: "worktree"; - series: "v3" | "v4"; + series: SiteConfig["series"][number]; }; export type ClonePackageOptions = { @@ -36,7 +44,7 @@ export interface Package { ref: Ref; exports: Record; entrypoints: Record; - docs: () => Operation; + docs: () => Operation; workspaces: string[]; jsrPackageDetails: () => Operation< [ @@ -68,9 +76,7 @@ export function* usePackage(options: PackageOptions): Operation { if (options.type === "worktree") { let repo = createRepo({ name: "effection", owner: "thefrontside" }); - let tags = yield* repo.tags( - new RegExp(`effection-${options.series}.*`), - ); + let tags = yield* repo.tags(new RegExp(`effection-${options.series}.*`)); let ref = findLatestSemverTag(tags); @@ -136,12 +142,29 @@ function* initPackage( return entrypoints; }, *docs() { - let docs: DocsPages = {}; + let docs: LocalDocsPages = {}; for (let [entrypoint, url] of Object.entries(pkg.entrypoints)) { const pages = yield* useDocPages(`${url}`); - docs[entrypoint] = pages[`${url}`]; + docs[entrypoint] = pages[`${url}`].map((page) => { + return { + ...page, + sections: page.sections.map((section) => ({ + ...section, + node: { + ...section.node, + location: { + ...section.node.location, + url: new URL( + `${relative(path, fileURLToPath(section.node.location.filename))}#L${section.node.location.line}`, + `${ref.url}/`, + ), + }, + }, + })), + }; + }); } return docs; diff --git a/www/lib/workspace.ts b/www/lib/workspace.ts index a949c8188..c42e4ec3d 100644 --- a/www/lib/workspace.ts +++ b/www/lib/workspace.ts @@ -1,6 +1,7 @@ import { Operation } from "effection"; import { useClone } from "./clones.ts"; import { Package, usePackage } from "./package.ts"; +import { resolve } from "@std/path"; export interface Workspace { url: string; @@ -33,7 +34,7 @@ export function* useWorkspace(nameWithOwner: string): Operation { packages.push( yield* usePackage({ type: "clone", - path: `${path}/${workspacePath}`, + path: resolve(path, workspacePath), workspacePath, ref: { name: "main", diff --git a/www/main.tsx b/www/main.tsx index a4d6e228a..23728bc49 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -1,4 +1,3 @@ -import { initDenoDeploy } from "@effectionx/deno-deploy"; import { main, suspend } from "effection"; import { createRevolution, ServerInfo } from "revolution"; @@ -14,9 +13,9 @@ import { indexRoute } from "./routes/index-route.tsx"; import { xIndexRedirect, xIndexRoute } from "./routes/x-index-route.tsx"; import { xPackageRedirect, xPackageRoute } from "./routes/x-package-route.tsx"; +import { initConfig, useConfig } from "./context/config.ts"; import { initFetch } from "./context/fetch.ts"; import { initJSRClient } from "./context/jsr.ts"; -import { patchDenoPermissionsQuerySync } from "./deno-deploy-patch.ts"; import { initWorktrees } from "./lib/worktrees.ts"; import { initGuides } from "./resources/guides.ts"; import { apiIndexRoute } from "./routes/api-index-route.tsx"; @@ -30,17 +29,13 @@ import { initOctokitContext } from "./lib/octokit.ts"; // Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts if (import.meta.main) { await main(function* () { - const denoDeploy = yield* initDenoDeploy(); - - // if (denoDeploy.isDenoDeploy) { - // patchDenoPermissionsQuerySync(); - // } + const { current, series } = yield* useConfig(); yield* initClones("build/clones"); yield* initWorktrees("build/worktrees"); yield* initGuides({ - current: "v4", - worktrees: ["v3"], + current, + worktrees: series.filter((s) => s !== current), }); yield* initJSRClient(); @@ -53,18 +48,20 @@ if (import.meta.main) { app: [ route("/", indexRoute()), route("/search", searchRoute()), - route("/docs", redirectIndexRoute(firstPage("v3"))), - route("/docs/:id", redirectDocsRoute("v3")), - route("/guides/v3", redirectIndexRoute(firstPage("v3"))), - route("/guides/v4", redirectIndexRoute(firstPage("v4"))), + route("/docs", redirectIndexRoute(firstPage(current))), + route("/docs/:id", redirectDocsRoute(current)), + ...series.map((s) => + route(`/guides/${s}`, redirectIndexRoute(firstPage(s))), + ), route("/guides/:series/:id", guidesRoute({ search: true })), route("/contrib", xIndexRedirect()), route("/contrib/:workspacePath", xPackageRedirect()), route("/x", xIndexRoute({ search: true })), route("/x/:workspacePath", xPackageRoute({ search: true })), route("/api", apiIndexRoute({ search: true })), - route("/api/v3/:symbol", apiReferenceRoute("v3", { search: true })), - route("/api/v4/:symbol", apiReferenceRoute("v4", { search: true })), + ...series.map((s) => + route(`/api/${s}/:symbol`, apiReferenceRoute(s, { search: true })), + ), route( "/pagefind{/*path}", pagefindRoute({ pagefindDir: "pagefind", publicDir: "./built/" }), diff --git a/www/routes/api-index-route.tsx b/www/routes/api-index-route.tsx index 0fdcd9110..bbd543a18 100644 --- a/www/routes/api-index-route.tsx +++ b/www/routes/api-index-route.tsx @@ -6,7 +6,7 @@ import { ResolveLinkFunction } from "../hooks/use-markdown.tsx"; import { usePackage } from "../lib/package.ts"; import { SitemapRoute } from "../plugins/sitemap.ts"; import { useAppHtml } from "./app.html.tsx"; -import { createChildURL } from "./links-resolvers.ts"; +import { createChildURL } from "../lib/links-resolvers.ts"; export function apiIndexRoute( { search }: { search: boolean }, @@ -41,7 +41,7 @@ export function apiIndexRoute(

    API Reference

    -

    +

    {v4.version}
    -

    +

    {v3.version} diff --git a/www/routes/api-reference-route.tsx b/www/routes/api-reference-route.tsx index 8fc10aa3a..2ab1a7064 100644 --- a/www/routes/api-reference-route.tsx +++ b/www/routes/api-reference-route.tsx @@ -5,9 +5,10 @@ import { useAppHtml } from "./app.html.tsx"; import { ApiPage } from "../components/api/api-page.tsx"; import { usePackage } from "../lib/package.ts"; -import { createSibling } from "./links-resolvers.ts"; +import { createSibling } from "../lib/links-resolvers.ts"; +import { SiteConfig } from "../context/config.ts"; -export function apiReferenceRoute(series: "v3" | "v4", { +export function apiReferenceRoute(series: SiteConfig["series"][number], { search, }: { search: boolean; diff --git a/www/routes/guides-route.tsx b/www/routes/guides-route.tsx index 0598e081c..c91811ad5 100644 --- a/www/routes/guides-route.tsx +++ b/www/routes/guides-route.tsx @@ -5,11 +5,14 @@ import { useDescription } from "../hooks/use-description-parse.tsx"; import { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; import { type GuidesMeta, useGuides } from "../resources/guides.ts"; import { useAppHtml } from "./app.html.tsx"; -import { createChildURL, createSibling } from "./links-resolvers.ts"; +import { + createChildURL, + createRootUrl, + createSibling, +} from "../lib/links-resolvers.ts"; import { Navburger } from "../components/navburger.tsx"; import { softRedirect } from "./redirect.tsx"; -import { IconExternal } from "../components/icons/external.tsx"; -import { createRepo } from "../lib/repo.ts"; +import { useConfig } from "../context/config.ts"; export function firstPage(series: string): () => Operation { return function* () { @@ -20,11 +23,6 @@ export function firstPage(series: string): () => Operation { }; } -const SERIES = ["v3", "v4"]; -const STABLE_SERIES = "v3"; - -const repo = createRepo({ name: "effection", owner: "thefrontside" }); - export function guidesRoute({ search, }: { @@ -32,6 +30,7 @@ export function guidesRoute({ }): SitemapRoute { return { *routemap(pathname) { + const { series: SERIES } = yield* useConfig(); const paths = SERIES.map(function* (series) { let paths: RoutePath[] = []; @@ -47,7 +46,9 @@ export function guidesRoute({ return (yield* all(paths)).flat(); }, *handler(req) { - let { id, series = STABLE_SERIES } = yield* useParams<{ + const { series: SERIES, current } = yield* useConfig(); + + let { id, series = current } = yield* useParams<{ id: string | undefined; series: string | undefined; }>(); @@ -85,20 +86,18 @@ export function guidesRoute({ for (const item of topic.items) { items.push(
  • - {page.id !== item.id - ? ( - - {item.title} - - ) - : ( - - {item.title} - - )} + {page.id !== item.id ? ( + + {item.title} + + ) : ( + + {item.title} + + )}
  • , ); } @@ -112,7 +111,22 @@ export function guidesRoute({ ); } - const latest = yield* repo.latest(new RegExp(`effection-${series}.*`)); + const versionToggle = yield* all( + SERIES.map(function* (s) { + return ( + + {s} + + ); + }), + ); return ( @@ -141,15 +155,10 @@ export function guidesRoute({ @@ -159,6 +168,20 @@ export function guidesRoute({ data-series={series} >

    {page.title}

    + {series !== current ? ( +
    + You're viewing documentation for an older version. Effection + {current} is now available.{" "} + + View ${current} docs β†’ + +
    + ) : ( + <> + )} <>{page.content} {yield* NextPrevLinks({ page })}

    @@ -179,32 +202,32 @@ function* NextPrevLinks({ page }: { page: GuidesMeta }): Operation { let { next, prev } = page; return ( - {prev - ? ( -
  • - Previous - - {prev.title} - -
  • - ) - :
  • } - {next - ? ( -
  • - Next - - {next.title} - -
  • - ) - :
  • } + {prev ? ( +
  • + Previous + + {prev.title} + +
  • + ) : ( +
  • + )} + {next ? ( +
  • + Next + + {next.title} + +
  • + ) : ( +
  • + )}
  • ); } diff --git a/www/routes/redirect-docs-route.tsx b/www/routes/redirect-docs-route.tsx index b1749d34a..8d79a453a 100644 --- a/www/routes/redirect-docs-route.tsx +++ b/www/routes/redirect-docs-route.tsx @@ -2,7 +2,7 @@ import { type JSXElement, useParams } from "revolution"; import { SitemapRoute } from "../plugins/sitemap.ts"; import { useGuides } from "../resources/guides.ts"; -import { createRootUrl } from "./links-resolvers.ts"; +import { createRootUrl } from "../lib/links-resolvers.ts"; import { softRedirect } from "./redirect.tsx"; export function redirectDocsRoute(series: string): SitemapRoute { diff --git a/www/routes/x-index-route.tsx b/www/routes/x-index-route.tsx index eb1721fcc..6521ca733 100644 --- a/www/routes/x-index-route.tsx +++ b/www/routes/x-index-route.tsx @@ -4,7 +4,7 @@ import { GithubPill } from "../components/package/source-link.tsx"; import { useWorkspace } from "../lib/workspace.ts"; import type { SitemapRoute } from "../plugins/sitemap.ts"; import { useAppHtml } from "./app.html.tsx"; -import { createChildURL, createSibling } from "./links-resolvers.ts"; +import { createChildURL, createSibling } from "../lib/links-resolvers.ts"; import { softRedirect } from "./redirect.tsx"; export function xIndexRedirect(): SitemapRoute { diff --git a/www/routes/x-package-route.tsx b/www/routes/x-package-route.tsx index 8ef5b54c4..02a64343e 100644 --- a/www/routes/x-package-route.tsx +++ b/www/routes/x-package-route.tsx @@ -19,7 +19,7 @@ import { createToc } from "../lib/toc.ts"; import { useWorkspace } from "../lib/workspace.ts"; import type { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; import { useAppHtml } from "./app.html.tsx"; -import { createSibling } from "./links-resolvers.ts"; +import { createSibling } from "../lib/links-resolvers.ts"; import { softRedirect } from "./redirect.tsx"; interface XPackageRouteParams { @@ -153,7 +153,7 @@ export function xPackageRoute({ customizeTOCItem(item, heading) { heading.properties.class = [ heading.properties.class, - `group scroll-mt-[100px]`, + `group grow scroll-mt-[100px]`, ] .filter(Boolean) .join(""); From b82cea3bde9f13e083c6fb1560f4816ddb95b9c2 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 22 Dec 2025 20:58:04 -0600 Subject: [PATCH 085/119] add DRAFT migration guide (#1024) --- docs/structure.json | 3 +- docs/upgrade.mdx | 225 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 docs/upgrade.mdx diff --git a/docs/structure.json b/docs/structure.json index cad56d0ae..acea1b81c 100644 --- a/docs/structure.json +++ b/docs/structure.json @@ -4,7 +4,8 @@ ["typescript.mdx", "TypeScript"], ["thinking-in-effection.mdx", "Thinking in Effection"], ["async-rosetta-stone.mdx", "Async Rosetta Stone"], - ["tutorial.mdx", "Tutorial"] + ["tutorial.mdx", "Tutorial"], + ["upgrade.mdx", "Upgrading from V3"] ], "Learn Effection": [ ["operations.mdx", "Operations"], diff --git a/docs/upgrade.mdx b/docs/upgrade.mdx new file mode 100644 index 000000000..66b7e3db4 --- /dev/null +++ b/docs/upgrade.mdx @@ -0,0 +1,225 @@ +For the most part, the changes between Effection versions 3 and 4 are +internal, and while the public API remains largely untouched , there +were some places where it was appropriate to make breaking changes. +However, in the cases where we did, it was guided by our simple +principle: to _embrace_ JavaScript, not fight against it. We asked +ourselvers: How can we make Effection APIs feel even _more_ natural +and familiar to JavaScript developers while providing "just enough" +functionality to bring all the wonderful benefits of structured +concurrency and effects to your applications. + +Among other things, this included the refinement of several key +functions that just did too much in v3. These changes make Effection +harmonize even more with the greate JavaScript ecosystem, but they do +require some attention during migration. + +## Simplifying `call()` + +In Effection, few APIs were as versatile as the [v3 `call()` +function][v3-call]. It could invoke functions, evaluate promises, +treat constants as operations, establish error boundaries, and even +manage concurrency to boot. But one piece of feedback that we +consistently got was "how does this relate to +`Function.prototype.call()." Sadly, the answer was: only tangentially. + +So in order to make using `call()` require no learning beyond how its +vanilla counterpart works, we've simplified it to what you would +expect from something named "call". It invokes functions as +operations, and that's it. + +When you're upgrading promise-based code, you'll now use the `until()` helper, which converts a promise into an operation: + +```js +let response = yield* call(fetch("https://frontside.com/effection")); +``` + +To do this in v4, use the [`until()`][until] utility function + +```js +let response = yield* until(fetch("https://frontside.com/effection")); +``` + +`v3` call also allowed for the rare cases where you need to evaluate a constant value as an operation. + +```js +let five = yield* call(5); +``` + +Now however, there's now a dedicated `constant()` helper: + +```js +let five = yield* constant(5); +``` + +`call()` could also be used in v3 to delimit a concurrency boundary which would terminate all children within its scope + +The most significant change involves how called operations are +delimited. In v3, `call()` automatically established both error +boundaries and concurrency boundaries around its body, meaning any +tasks spawned within a `call()` would be automatically cleaned up when +the call completed. In v4, `call()` no longer does thisβ€”it simply +invokes the function and returns its result. + +```js +// v4 - call() does NOT establish boundaries +yield* call(function*() { + // spawned tasks here are NOT terminated before call returns its value + yield* spawn(someBackgroundTask); + return "done"; +}); +``` + +If you need those boundariesβ€”and you often willβ€”use the new `scoped()` function: + +```js +// v4 - scoped() establishes boundaries +yield* scoped(function*() { + // spawned tasks here ARE terminated before the scoped body returns + yield* spawn(someBackgroundTask); + return "done"; +}); +``` + +## Rethinking `action()` + +The `action()` function has undergone a similar simplification. In our documentation, we describe `action()` as Effection's equivalent to `new Promise()`, and v4 makes this analogy much stronger. + +In v3, an action is written using an operation. For example, consider this implementation of a `sleep()` operation: + +```js +// v3 operation function +function sleep(milliseconds) { + return action(function*(resolve) { + let timeout = setTimeout(resolve, milliseconds); + try { + yield* suspend(); + } finally { + clearTimeout(timeout); + } + }); +} +``` + +The v4 equivalent looks much more like a Promise constructor: + +```js +// v4 - action strongly resembles Promise +function sleep(milliseconds) { + return action((resolve, reject) => { + let timeout = setTimeout(resolve, milliseconds); + return () => { clearTimeout(timeout); } + }); +} +``` + +Notice how the v4 version takes a synchronous function that receives +both `resolve` and `reject` callbacks, just like `new Promise()`. +Instead of using `try/finally` blocks for teardown, you just return a +synchronous cleanup function. + +Like the simplified `call()`, actions in v4 no longer establish their +own concurrency or error boundaries. If you need boundaries around +action usage, wrap the call with `scoped()`. + +## Task Execution Priority + +The most subtle but important change in v4 involves how tasks are +scheduled for execution. This change won't trigger any deprecation +warnings, but it can affect the behavior of your applications in ways +that might not be immediately obvious. + +In v3, tasks ran immediately whenever an event came in that caused it +to resume. A child task spawned in the background would start +executing immediately, even while its parent was still running +synchronous code. v4 changes this: a parent task always has priority +over its children. + +Consider this example: + +```js +await run(function* example() { + console.log('parent: start'); + yield* spawn(function*() { + console.log('child: start'); + yield* sleep(10); + console.log('child: end'); + }); + console.log('parent: middle'); + // Lots of synchronous work here + for (let i = 0; i < 1000; i++) { + // The child won't run during this loop in v4 + } + console.log('parent: before async'); + yield* sleep(100); // This is when the child finally gets to run + console.log('parent: end'); +}) +``` + +In v3, you would see output like this: + +``` +parent: start +child: start +parent: middle +parent: before async +parent: end +child: end +``` + +But in v4, the output changes to: + +``` +parent: start +parent: middle +parent: before async +child: start +parent: end +child: end +``` + +The key difference is that the child task doesn't get to run until the +parent yields control to a truly asynchronous operationβ€”in this case, +`sleep(1)`. Purely synchronous operations, even when wrapped with +`yield* call()`, won't give child tasks a chance to execute. Furthermore, in cases where a single event schedules multiple tasks to resume, the parent task will resume first. + +This change makes task execution more predictable and allows parents +to always have the necessary priority required to supervise the +execution of their children. However, it does mean that if your v3 +code relied on child tasks starting immediately, you might need to +adjust your approach. + +## Migration Strategies + +Most of the changes from v3 to v4 will be caught by deprecation +warnings, making the upgrade process straightforward. The `call()` and +`action()` changes are mechanicalβ€”update the syntax, import the new +helpers, and you're done. + +The task execution priority change requires more thought. If your +application has timing-sensitive code where child tasks need to start +immediately, you have several approaches: + +You can restructure your parent tasks to yield control explicitly by +adding asynchronous operations where needed. Even `yield* sleep(0)` +will give child tasks a chance to start: + +```js +function* parent() { + yield* spawn(childTask); + yield* sleep(0); // Yields control to child immediately + // Continue with parent work +} +``` + +You can also reconsider your task hierarchy. Sometimes what you +thought needed to be a parent-child relationship might work better as +sibling tasks within a shared scope. + + +If you encounter issues during upgrade, please file an +[issue](https://github.com/thefrontside/effection/issues/new). Not +only will that allow us to lend a hand, but it will also give us an +opportunity to improve this migration guide! + +[v3-call]: https://frontside.com/effection/api/v3/call/ +[until]: https://frontside.com/effection/api/v4/until/ \ No newline at end of file From 940b602d9b692c7e33f1f0000f3887d2f0a8c1a5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 22 Dec 2025 22:14:43 -0500 Subject: [PATCH 086/119] Add missing v4 changelog entries (#1053) --- Changelog.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/Changelog.md b/Changelog.md index 381a1c71c..fcf9f7501 100644 --- a/Changelog.md +++ b/Changelog.md @@ -91,6 +91,57 @@ - Initial v4 release with delimited continuations +## 3.6.1 + +- Do not bind SIGTERM in Windows + https://github.com/thefrontside/effection/pull/1031 + +## 3.6.0 + +- ✨ add interval() helper to consume a stream of intervals + https://github.com/thefrontside/effection/pull/1005 + +## 3.5.1 + +- πŸ› Fix Webpack compatibility through a specific comment to ignore the dynamic + `node:process` import https://github.com/thefrontside/effection/pull/1007 + +## 3.5.0 + +- πŸš› Backport createScope(parent) to v3 + https://github.com/thefrontside/effection/pull/996 +- πŸš› Backport Scope.spawn() to v3 + https://github.com/thefrontside/effection/pull/995 + +## 3.4.0 + +- Introducing until helper to replace call(promise) + https://github.com/thefrontside/effection/pull/988 + +## 3.3.0 + +- πŸŽ’Backport Context.with() https://github.com/thefrontside/effection/pull/982 +- πŸŽ’backport `each()` πŸ› fixes to v3 + https://github.com/thefrontside/effection/pull/980 +- πŸŽ’backport advanced scope helpers from v4 -> v3 + https://github.com/thefrontside/effection/pull/967 + +## 3.2.0 + +- ✨ Backfill the `scoped()` API + https://github.com/thefrontside/effection/pull/964 +- ✨ Backport `withResolvers()` from v4 to v3 + https://github.com/thefrontside/effection/pull/963 + +## 3.1.0 + +- πŸ› dynamically import node process + https://github.com/thefrontside/effection/pull/935 +- Deprecate non-function invocations of call() + https://github.com/thefrontside/effection/pull/929 +- backport `Context#expect()` to v3 + https://github.com/thefrontside/effection/commit/4d3be6c1be3f4c3c7dfbfd5435d07d754023800a + ## 3.0.3 - this is just a placeholder release in order to workaround an issue. It is 100% From 321e9795f4aa1b0144b0f1f221393199279ce509 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 22 Dec 2025 23:25:27 -0500 Subject: [PATCH 087/119] Added JSR and fixed link to v4 guides (#1055) --- docs/installation.mdx | 4 ++-- www/routes/guides-route.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation.mdx b/docs/installation.mdx index bdea3161d..815591393 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -15,10 +15,10 @@ yarn add effection ## Deno -Effection has first class support for Deno because it is developed with [Deno](https://deno.land). Releases are published to [https://deno.land/x/effection](https://deno.land/x/effection). For example, to import the `main()` function: +Effection has first class support for Deno because it is developed with [Deno](https://deno.land). Releases are published to [JSR](https://jsr.io/@effection/effection) and [https://deno.land/x/effection](https://deno.land/x/effection). For example, to import the `main()` function: ```ts -import { main } from "jsr:@effection/effection@4.0.0-beta.2"; +import { main } from "jsr:@effection/effection@4.0.0"; ``` > πŸ’‘ If you're curious how we keep NPM/YARN and Deno packages in-sync, you can [checkout the blog post on how publish Deno packages to NPM.][deno-npm-publish]. diff --git a/www/routes/guides-route.tsx b/www/routes/guides-route.tsx index c91811ad5..fb98c34fd 100644 --- a/www/routes/guides-route.tsx +++ b/www/routes/guides-route.tsx @@ -176,7 +176,7 @@ export function guidesRoute({ href={yield* createRootUrl("docs")(page.id)} class="font-medium text-sky-500 hover:underline" > - View ${current} docs β†’ + View {current} docs β†’ ) : ( From 9d28e2b283f2bcce0269c74011b7f8a2d2fb256c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 22 Dec 2025 23:21:21 -0600 Subject: [PATCH 088/119] Prepare v4 Changelog (#1043) * Added JSR and fixed link to v4 guides * Prepare v4 Changelog Resolves https://github.com/thefrontside/effection/issues/1019 This compiles all the alpha and beta logs into a single changelog descending from v3 * collapse and collate v4 changelog * fixup formatting --------- Co-authored-by: Taras Mankovski --- Changelog.md | 43 +++---------------------------------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/Changelog.md b/Changelog.md index fcf9f7501..27967ee42 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,45 +1,23 @@ # Changelog -## 4.0.0-beta.3 - -- Do not bind SIGTERM in Windows for v4 - https://github.com/thefrontside/effection/pull/1032 - -## 4.0.0-beta.2 +## 4.0.0 +- Announcing Effection 4.0 + https://frontside.com/blog/2025-12-23-announcing-effection-v4/ - run resources at the priority level of their caller https://github.com/thefrontside/effection/pull/1017 -- add interval() helper to consume a stream of intervals, port from v3 - https://github.com/thefrontside/effection/pull/1016 - -## 4.0.0-beta.1 - - properly propagate errors from nested scopes https://github.com/thefrontside/effection/pull/1014 - -## 4.0.0-alpha.8 - -- Add `until()` operation for turning promises into operations - https://github.com/thefrontside/effection/pull/990 - Add Effect.js benchmarks for performance comparison https://github.com/thefrontside/effection/pull/979 - -## 4.0.0-alpha.7 - - Fix type definition for call operation https://github.com/thefrontside/effection/pull/973 - Fix missing tag issue https://github.com/thefrontside/effection/pull/970 - Minor documentation improvements https://github.com/thefrontside/effection/pull/971 - -## 4.0.0-alpha.6 - - Remove unnecessary www from deploy preview URLs https://github.com/thefrontside/effection/pull/969 - Add promise helpers https://github.com/thefrontside/effection/pull/968 - -## 4.0.0-alpha.5 - - Test against Node 16, 18, 20 versions https://github.com/thefrontside/effection/pull/966 - Add documentation for scope https://github.com/thefrontside/effection/pull/961 @@ -62,33 +40,18 @@ - Add withResolvers documentation https://github.com/thefrontside/effection/pull/940 - Remove v2 documentation https://github.com/thefrontside/effection/pull/938 - -## 4.0.0-alpha.4 - - Add dynamic import for Node.js main https://github.com/thefrontside/effection/pull/936 - Add scoped operation https://github.com/thefrontside/effection/pull/933 - Export Result interface from API https://github.com/thefrontside/effection/pull/920 - Add benchmark suite https://github.com/thefrontside/effection/pull/919 - -## 4.0.0-alpha.3 - - Add do-effect helper https://github.com/thefrontside/effection/pull/918 - Use Deno 2.0 https://github.com/thefrontside/effection/pull/917 - -## 4.0.0-alpha.2 - - Make Task implement Operation https://github.com/thefrontside/effection/pull/915 - -## 4.0.0-alpha.1 - - Export async helpers in main API https://github.com/thefrontside/effection/pull/913 - -## 4.0.0-alpha.0 - - Initial v4 release with delimited continuations ## 3.6.1 From e0663af8869a920fde498bf902ce13f8e06e24ec Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 23 Dec 2025 01:05:09 -0600 Subject: [PATCH 089/119] =?UTF-8?q?=F0=9F=8E=AF=20publish=20v4=20on=20"lat?= =?UTF-8?q?est"=20tag=20(#1057)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🎯 publish v4 on "latest" tag v4 is now the current release, so newest NPM packages are published with the `latest` tag * remove obsolete token --- .github/workflows/publish.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef13ce2ed..ab45a8045 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,10 +36,8 @@ jobs: run: deno task build:npm ${{steps.vars.outputs.version}} - name: Publish NPM - run: npm publish --access=public --tag=next + run: npm publish --access=public --tag=latest working-directory: ./build/npm - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} publish-jsr: runs-on: ubuntu-latest From 7d6f0d6c69df84a766b27ab2317f5d39f7665ca0 Mon Sep 17 00:00:00 2001 From: Jack <48762591+frontsidejack@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:59:51 -0500 Subject: [PATCH 090/119] Update publish.yml (#1059) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ab45a8045..895ab45d7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4.1.0 with: - node-version: 18.x + node-version: 24 registry-url: https://registry.npmjs.com - name: Build NPM From 89767245feced67145a24af0651222716d93c3d4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 23 Dec 2025 15:27:51 -0500 Subject: [PATCH 091/119] Change license from ISC to MIT (#1060) --- tasks/build-npm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index 529938c6b..51ea9acff 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -27,7 +27,7 @@ await build({ name: "effection", version, description: "Structured concurrency and effects for JavaScript", - license: "ISC", + license: "MIT", author: "engineering@frontside.com", repository: { type: "git", From dad87c334f5ad49c2a26d42685543c6ccbb206c1 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Tue, 23 Dec 2025 14:51:24 -0600 Subject: [PATCH 092/119] use v6 GHA setup-node (#1061) --- .github/workflows/publish.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 895ab45d7..d2442f9eb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,10 +27,9 @@ jobs: run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^effection-v//') - name: Setup Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v6 with: node-version: 24 - registry-url: https://registry.npmjs.com - name: Build NPM run: deno task build:npm ${{steps.vars.outputs.version}} From a31443368b4338ddd42a9c8270506c6e770104ce Mon Sep 17 00:00:00 2001 From: Jack <48762591+frontsidejack@users.noreply.github.com> Date: Sun, 28 Dec 2025 11:17:25 -0500 Subject: [PATCH 093/119] Revert to using case sensitive file system in Linux to build the website (#1067) --- .github/workflows/www.yaml | 12 ++++++------ www/deno.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/www.yaml b/.github/workflows/www.yaml index 92e78aa41..a4da4bfde 100644 --- a/.github/workflows/www.yaml +++ b/.github/workflows/www.yaml @@ -10,7 +10,7 @@ on: jobs: www: - runs-on: macos-latest + runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -43,14 +43,14 @@ jobs: - name: Download Staticalize run: | - wget https://github.com/thefrontside/staticalize/releases/download/v0.2.2/staticalize-macos.tar.gz \ - -O /tmp/staticalize-macos.tar.gz - tar -xzf /tmp/staticalize-macos.tar.gz -C /usr/local/bin - chmod +x /usr/local/bin/staticalize-macos + wget https://github.com/thefrontside/staticalize/releases/download/v0.2.2/staticalize-linux.tar.gz \ + -O /tmp/staticalize-linux.tar.gz + tar -xzf /tmp/staticalize-linux.tar.gz -C /usr/local/bin + chmod +x /usr/local/bin/staticalize-linux - name: Staticalize run: | - staticalize-macos \ + staticalize-linux \ --site=http://127.0.0.1:8000 \ --output=www/built \ --base=https://effection-www.deno.dev/ diff --git a/www/deno.json b/www/deno.json index fe0b21118..6a6762278 100644 --- a/www/deno.json +++ b/www/deno.json @@ -1,7 +1,7 @@ { "tasks": { "dev": "deno run -A @effectionx/watch deno run -A main.tsx", - "staticalize": "deno run -A jsr:@frontside/staticalize@0.2.1/cli --site http://localhost:8000 --output=built --base=http://localhost:8000", + "staticalize": "deno run -A jsr:@frontside/staticalize@0.2.2/cli --site http://localhost:8000 --output=built --base=http://localhost:8000", "pagefind": "npx pagefind --site built", "test": "deno test --allow-run --allow-write --allow-read" }, From 7ac9d2db6d018c78aef6d228f5533ea992aa2344 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 28 Dec 2025 15:41:07 -0500 Subject: [PATCH 094/119] Fix v3/v4 navigation to non-existent page and mobile sidebar in dark mode (#1068) * Fix menu dark mode in responsive * Added a bit of padding to the footer and changed v4 link * Formatted MD files * Added until to faq * Added until to faq * Fix #1058 Guide navigation links don't work if the same page does not exist on other guide. --- CODE_OF_CONDUCT.md | 64 ++++++++++++++++++++++++++----------- README.md | 7 ++-- docs/faq.mdx | 8 ++--- www/components/footer.tsx | 6 ++-- www/routes/guides-route.tsx | 14 ++++---- 5 files changed, 64 insertions(+), 35 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 65e348822..32fba281e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,45 +2,73 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of +experience, nationality, personal appearance, race, religion, or sexual identity +and orientation. ## Our Standards -Examples of behavior that contributes to creating a positive environment include: +Examples of behavior that contributes to creating a positive environment +include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Our Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at oss@frontside.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at oss@frontside.com. The project team +will review and investigate all complaints, and will respond in a way that it +deems appropriate to the circumstances. The project team is obligated to +maintain confidentiality with regard to the reporter of an incident. Further +details of specific enforcement policies may be posted separately. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/README.md b/README.md index f559a45ef..18e093e3b 100755 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ solid _at scale_, and it does all of this while feeling like normal JavaScript. ## Platforms Effection runs on all major JavaScript platforms including NodeJs, Browser, and -Deno. It is published on both [npm][npm-effection] and [deno.land][deno-land-effection]. +Deno. It is published on both [npm][npm-effection] and +[deno.land][deno-land-effection]. ## Contributing to Website @@ -28,7 +29,7 @@ Go to [website's readme](www) to learn how to contribute to the website. ## Development -[Deno][] is the primary tool used for development, testing, and packaging. +[Deno][Deno] is the primary tool used for development, testing, and packaging. ### Testing @@ -44,7 +45,7 @@ If you want to build a development version of the NPM package so that you can link it locally, you can use the `build:npm` script and passing it a version number. for example: -``` text +```text $ deno task build:npm 3.0.0-mydev-snapshot.0 Task build:npm deno run -A tasks/build-npm.ts "3.0.0-mydev-snapshot.0" [dnt] Transforming... diff --git a/docs/faq.mdx b/docs/faq.mdx index 3cbce1ff2..94df657d2 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -41,7 +41,7 @@ Object.defineProperty(Promise.prototype, Symbol.iterator, { ## Why can't I use `call(Promise.resolve(42))` anymore? -In Effection v3, it was possible to `yield* call(Promise.resolve(42))` but we removed this in Effection v4 -because we wanted to align call closer to `Function.prototype.call`. You can still use -`yield* call(() => Promise.resolve(42))` and `yield* call(async function() {})`. In v4, we plan to ship -a new function called `when` which will take a promise and wrap it in a constructor for convenience. +[until()](/api/v4/until) function offers a convenient alternative to passing a promise to `call`. In Effection v3, it was possible +to `yield* call(Promise.resolve(42))` but we removed this in Effection v4 because we wanted to align call closer +to `Function.prototype.call`. You can still use `yield* call(() => Promise.resolve(42))` and +`yield* call(async function() {})`. diff --git a/www/components/footer.tsx b/www/components/footer.tsx index b5a89cc88..be0603d51 100644 --- a/www/components/footer.tsx +++ b/www/components/footer.tsx @@ -2,7 +2,7 @@ import { IconExternal } from "./icons/external.tsx"; export function Footer(): JSX.Element { return ( -