├── .github └── workflows │ ├── npm-release.yml │ └── verify.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── CHANGELOG.md ├── README.md ├── deno.json ├── jsr.json ├── mod.ts ├── npm.ts └── t ├── asserts.ts ├── bdd.ts ├── continuation.test.ts └── error.test.ts /.github/workflows/npm-release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Deno then run Deno lint and test. 2 | # For more information see: https://github.com/denoland/setup-deno 3 | 4 | name: Release to NPM 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: setup deno 23 | # uses: denoland/setup-deno@v1 24 | uses: denoland/setup-deno@004814556e37c54a2f6e31384c9e18e983317366 25 | with: 26 | deno-version: v1.x 27 | 28 | - name: Get Version 29 | id: vars 30 | run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^v//') 31 | 32 | 33 | - name: Setup Node 34 | uses: actions/setup-node@v2 35 | with: 36 | node-version: 14.x 37 | registry-url: https://registry.npmjs.com 38 | 39 | - name: Build 40 | run: deno task npm:build 41 | env: 42 | NPM_VERSION: ${{steps.vars.outputs.version}} 43 | 44 | - name: Publish 45 | run: npm publish --access=public 46 | working-directory: ./npm 47 | env: 48 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 49 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow will install Deno then run Deno lint and test. 7 | # For more information see: https://github.com/denoland/setup-deno 8 | 9 | name: Lint and Test 10 | 11 | on: 12 | push: 13 | branches: [v0] 14 | pull_request: 15 | branches: [v0] 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: checkout 26 | uses: actions/checkout@v3 27 | 28 | - name: setup deno 29 | # uses: denoland/setup-deno@v1 30 | uses: denoland/setup-deno@004814556e37c54a2f6e31384c9e18e983317366 31 | with: 32 | deno-version: v1.x 33 | 34 | # Uncomment this step to verify the use of 'deno fmt' on each commit. 35 | # - name: Verify formatting 36 | # run: deno fmt --check 37 | 38 | - name: lint 39 | run: deno lint 40 | 41 | - name: test 42 | run: deno test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /npm/ 2 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | RUN curl -fsSL https://deno.land/x/install/install.sh | sh 4 | RUN /home/gitpod/.deno/bin/deno completions bash > /home/gitpod/.bashrc.d/90-deno && echo 'export DENO_INSTALL="/home/gitpod/.deno"' >> /home/gitpod/.bashrc.d/90-deno && echo 'export PATH="$DENO_INSTALL/bin:$PATH"' >> /home/gitpod/.bashrc.d/90-deno 5 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## \[0.1.5] 4 | 5 | - migrate from Deno.test to BDD https://deno.com/blog/v1.21#bdd-style-testing 6 | - fix bug where continuations were not re-raising the same error when invoked 7 | multiple times https://github.com/thefrontside/continuation/pull/9 8 | - add a `.tail()` method to both `resolve` and `reject` continuations that will 9 | re-use the currently executing reduction loop rather than calling reduce 10 | recursively and adding another JS call frame to the stack. 11 | https://github.com/thefrontside/continuation/pull/11 12 | 13 | ## \[0.1.4] 14 | 15 | - now that automatic cross releasing is working for both deno.land/x and 16 | npmjs.org, this has both release matching the exact same commit. 17 | 18 | ## \[0.1.3] 19 | 20 | - debugging cross release to both deno.land/x and npm, do not use. 21 | 22 | ## \[0.1.2] 23 | 24 | - re-release since npm and deno.land had different versions 25 | 26 | ## \[0.1.1] 27 | 28 | - include README.md in NPM release 29 | 30 | ## \[0.1.0] 31 | 32 | - add support for raising errors within a continuation 33 | 34 | ## \[0.0.2] 35 | 36 | - include sourcemaps for npm distribution 37 | - support iterative evaluation instead of recursive evaluation. This means that 38 | stacktraces are considerably smaller whenever an evaluation resumes 39 | 40 | ## \[0.0.1] 41 | 42 | - initial release, support for one-shot `evaluate, reset, shift` with no way to 43 | raise errors 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Continuation 2 | 3 | [Delimited continuations](https://en.wikipedia.org/wiki/Delimited_continuation) for JavaScript 4 | 5 | ## Install 6 | 7 | - **deno** https://deno.land/x/continuation/mod.ts 8 | - **npm** [@frontside/continuation](https://www.npmjs.com/package/@frontside/continuation) 9 | 10 | ## Synopsis 11 | 12 | ```typescript 13 | //deno 14 | import { evaluate } from "https://deno.land/x/continuation/mod.ts"; 15 | //npm 16 | import { evaluate } from "@frontside/continuation"; 17 | 18 | evaluate(function* () { 19 | for (let i = 5; i > 0; i--) { 20 | console.log(`${i}...`); 21 | yield* shift(function* (resume) { 22 | setTimeout(resume, 1000); 23 | }); 24 | } 25 | console.log("blast off!"); 26 | }); 27 | ``` 28 | 29 | prints: 30 | 31 | ```text 32 | 5... 33 | 4... 34 | 3... 35 | 2... 36 | 1... 37 | blast off! 38 | ``` 39 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "npm:build": "deno run -A npm.ts" 4 | }, 5 | "lint": { 6 | "rules": { 7 | "exclude": ["prefer-const", "require-yield"] 8 | }, 9 | "files": { 10 | "exclude": ["npm/"] 11 | } 12 | }, 13 | "fmt": { 14 | "files": { 15 | "exclude": ["README.md", "npm/"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frontside/continuation", 3 | "version": "0.1.6", 4 | "exports": "./mod.ts" 5 | } 6 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | 3 | export interface Computation { 4 | [Symbol.iterator](): Iterator; 5 | } 6 | 7 | export interface Continuation { 8 | (value: T extends void ? void : T): Computation; 9 | } 10 | 11 | export type Result = { ok: true; value: T } | { ok?: false; error: Error }; 12 | 13 | export type Control = 14 | | { 15 | type: "shift"; 16 | block(k: Continuation, reject: Continuation): Computation; 17 | } 18 | | { 19 | type: "reset"; 20 | block(): Computation; 21 | } 22 | | { 23 | type: "resume"; 24 | result: Result; 25 | iter: Iterator; 26 | }; 27 | 28 | export function* reset(block: () => Computation): Computation { 29 | return yield { type: "reset", block }; 30 | } 31 | 32 | export function* shift( 33 | block: ( 34 | k: Continuation, 35 | reject: Continuation, 36 | ) => Computation, 37 | ): Computation { 38 | return yield { type: "shift", block }; 39 | } 40 | 41 | export function evaluate(block: () => Computation) { 42 | let stack: Iterator[] = []; 43 | let iter = block()[Symbol.iterator](); 44 | 45 | let current = $next(undefined); 46 | while (true) { 47 | let result = safe(() => current(iter)); 48 | if (!result.ok) { 49 | if (stack.length > 0) { 50 | iter = stack.pop()!; 51 | current = $throw(result.error); 52 | } else { 53 | throw result.error; 54 | } 55 | } else { 56 | let next = result.value; 57 | if (next.done) { 58 | if (stack.length > 0) { 59 | iter = stack.pop()!; 60 | current = $next(next.value); 61 | } else { 62 | return next.value as T; 63 | } 64 | } else { 65 | const control = next.value; 66 | if (control.type === "reset") { 67 | stack.push(iter); 68 | iter = control.block()[Symbol.iterator](); 69 | } else if (control.type === "shift") { 70 | const continuation = iter; 71 | 72 | let resolve = oneshot((value: unknown) => ({ 73 | type: "resume", 74 | iter: continuation, 75 | result: { ok: true, value }, 76 | })); 77 | 78 | let reject = oneshot((error: Error) => { 79 | return { type: "resume", iter: continuation, result: { error } }; 80 | }); 81 | iter = control.block(resolve, reject)[Symbol.iterator](); 82 | } else { 83 | iter = control.iter; 84 | let { result } = control; 85 | current = result.ok ? $next(result.value) : $throw(result.error); 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | function $next(value: unknown) { 93 | return (iter: Iterator) => iter.next(value); 94 | } 95 | 96 | function $throw(error: Error): ReturnType { 97 | return (iter) => { 98 | if (iter.throw) { 99 | return iter.throw(error); 100 | } else { 101 | throw error; 102 | } 103 | }; 104 | } 105 | 106 | function safe(fn: () => T): Result { 107 | try { 108 | return { ok: true, value: fn() }; 109 | } catch (error) { 110 | return { error }; 111 | } 112 | } 113 | 114 | function oneshot( 115 | fn: (arg: TArg) => Control, 116 | ): (arg: TArg) => Computation { 117 | let computation: Computation | undefined = undefined; 118 | 119 | return function k(arg: TArg) { 120 | if (!computation) { 121 | let control = fn(arg); 122 | let iterator: Iterator = { 123 | next() { 124 | iterator.next = () => ({ 125 | done: true, 126 | value: undefined as unknown as T, 127 | }); 128 | return { done: false, value: control }; 129 | }, 130 | }; 131 | computation = { [Symbol.iterator]: () => iterator }; 132 | } 133 | return computation; 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /npm.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "https://deno.land/x/dnt@0.17.0/mod.ts"; 2 | import { assert } from "./t/asserts.ts"; 3 | await emptyDir("./npm"); 4 | 5 | let version = Deno.env.get("NPM_VERSION"); 6 | assert(version, "NPM_VERSION is required to build npm package"); 7 | 8 | await build({ 9 | entryPoints: ["./mod.ts"], 10 | outDir: "./npm", 11 | shims: { 12 | deno: false, 13 | }, 14 | test: false, 15 | typeCheck: false, 16 | compilerOptions: { 17 | target: "ES2020", 18 | sourceMap: true, 19 | }, 20 | package: { 21 | // package.json properties 22 | name: "@frontside/continuation", 23 | version, 24 | description: "Delimited continuations for JavaScript", 25 | license: "MIT", 26 | repository: { 27 | author: "engineering@frontside.com", 28 | type: "git", 29 | url: "git+https://github.com/thefrontside/continuation.git", 30 | }, 31 | bugs: { 32 | url: "https://github.com/thefrontside/continuation/issues", 33 | }, 34 | engines: { 35 | node: ">= 14", 36 | }, 37 | sideEffects: false, 38 | }, 39 | }); 40 | 41 | await Deno.copyFile("README.md", "npm/README.md"); 42 | -------------------------------------------------------------------------------- /t/asserts.ts: -------------------------------------------------------------------------------- 1 | export * from "https://deno.land/std@0.129.0/testing/asserts.ts"; 2 | -------------------------------------------------------------------------------- /t/bdd.ts: -------------------------------------------------------------------------------- 1 | export * from "https://deno.land/std@0.136.0/testing/bdd.ts"; 2 | -------------------------------------------------------------------------------- /t/continuation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "./bdd.ts"; 2 | import { assertEquals, assertThrows } from "./asserts.ts"; 3 | import { Computation, Continuation, evaluate, reset, shift } from "../mod.ts"; 4 | 5 | describe("continuation", () => { 6 | it("evaluates synchronous values synchronously", () => { 7 | assertEquals( 8 | 5, 9 | evaluate(function* () { 10 | return 5; 11 | }), 12 | ); 13 | }); 14 | 15 | it("evaluates synchronous shifts synchronously", () => { 16 | assertEquals( 17 | 5, 18 | evaluate(function* () { 19 | yield* shift(function* () { 20 | return 5; 21 | }); 22 | }), 23 | ); 24 | }); 25 | 26 | it("each continuation point function only resumes once", () => { 27 | let beginning, middle, end; 28 | let next = evaluate>>( 29 | function* () { 30 | beginning = true; 31 | middle = yield* shift(function* (k) { 32 | return k; 33 | }); 34 | end = yield* shift(function* (k) { 35 | return k; 36 | }); 37 | return end * 10; 38 | }, 39 | ); 40 | 41 | assertEquals(true, beginning); 42 | assertEquals(undefined, middle); 43 | 44 | let last = evaluate<(val: number) => Computation>(() => 45 | next("reached middle") 46 | ); 47 | assertEquals("reached middle", middle); 48 | assertEquals(undefined, end); 49 | assertEquals("function", typeof last); 50 | 51 | let second = evaluate(() => next("continue")); 52 | assertEquals("reached middle", middle); 53 | assertEquals(undefined, end); 54 | assertEquals(void 0, second); 55 | 56 | let result = evaluate(() => last(10)); 57 | assertEquals(10, end); 58 | assertEquals(100, result); 59 | 60 | let result2 = evaluate(() => last(100)); 61 | assertEquals(10, end); 62 | assertEquals(undefined, result2); 63 | }); 64 | 65 | it("each continuation point only fails once", () => { 66 | let bing = 0; 67 | let boom = evaluate>(function* () { 68 | yield* shift(function* (k) { 69 | return k; 70 | }); 71 | throw new Error(`bing ${++bing}`); 72 | }); 73 | 74 | assertThrows(() => evaluate(() => boom()), Error, "bing 1"); 75 | assertEquals(undefined, evaluate(() => boom())); 76 | }); 77 | 78 | it("can exit early from recursion", () => { 79 | function* times([first, ...rest]: number[]): Computation { 80 | if (first === 0) { 81 | return yield* shift(function* () { 82 | return 0; 83 | }); 84 | } else if (first == null) { 85 | return 1; 86 | } else { 87 | return first * (yield* times(rest)); 88 | } 89 | } 90 | 91 | assertEquals(0, evaluate(() => times([8, 0, 5, 2, 3]))); 92 | assertEquals(240, evaluate(() => times([8, 1, 5, 2, 3]))); 93 | }); 94 | 95 | it("returns the value of the following shift point when continuing ", () => { 96 | let { k } = evaluate<{ k: Continuation }>(function* () { 97 | let k = yield* reset(function* () { 98 | let result = yield* shift(function* (k) { 99 | return k; 100 | }); 101 | 102 | return yield* shift(function* () { 103 | return result * 2; 104 | }); 105 | }); 106 | return { k }; 107 | }); 108 | assertEquals("function", typeof k); 109 | assertEquals(10, evaluate(() => k(5))); 110 | }); 111 | 112 | it("can withstand stack overflow", () => { 113 | let result = evaluate(function* run() { 114 | let sum = 0; 115 | for (let i = 0; i < 100_000; i++) { 116 | sum += yield* shift<1>(function* incr(k) { 117 | return yield* k(1); 118 | }); 119 | } 120 | return sum; 121 | }); 122 | assertEquals(result, 100_000); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /t/error.test.ts: -------------------------------------------------------------------------------- 1 | import { Continuation, evaluate, reset, shift } from "../mod.ts"; 2 | import { assertEquals, assertObjectMatch, assertThrows } from "./asserts.ts"; 3 | import { describe, it } from "./bdd.ts"; 4 | 5 | describe("error", () => { 6 | it("is raised from evaluate", () => { 7 | assertThrows( 8 | () => 9 | evaluate(function* () { 10 | throw new Error("boom!"); 11 | }), 12 | Error, 13 | "boom!", 14 | ); 15 | }); 16 | 17 | it("is raised from a reset", () => { 18 | assertThrows( 19 | () => 20 | evaluate(function* () { 21 | yield* reset(function* () { 22 | throw new Error("boom!"); 23 | }); 24 | }), 25 | Error, 26 | "boom!", 27 | ); 28 | }); 29 | 30 | it("is raised from a shift", () => { 31 | assertThrows( 32 | () => 33 | evaluate(function* () { 34 | yield* reset(function* () { 35 | yield* shift(function* () { 36 | throw new Error("boom!"); 37 | }); 38 | }); 39 | }), 40 | Error, 41 | "boom!", 42 | ); 43 | }); 44 | 45 | it("can be caught from a shift", () => { 46 | assertObjectMatch( 47 | evaluate(function* () { 48 | try { 49 | yield* reset(function* () { 50 | yield* shift(function* () { 51 | throw new Error("boom!"); 52 | }); 53 | }); 54 | } catch (error) { 55 | return error; 56 | } 57 | }), 58 | { message: "boom!" }, 59 | ); 60 | }); 61 | 62 | it("can be raised programatically from a shift", () => { 63 | let reject = evaluate>(function* () { 64 | try { 65 | yield* shift(function* (_, reject) { 66 | return reject; 67 | }); 68 | } catch (error) { 69 | return { caught: true, error }; 70 | } 71 | }); 72 | 73 | let error = new Error("boom!"); 74 | 75 | assertEquals({ caught: true, error }, evaluate(() => reject(error))); 76 | }); 77 | 78 | it("blows up the caller if it is not caught inside the continuation", () => { 79 | let reject = evaluate>(function* () { 80 | yield* shift(function* (_, reject) { 81 | return reject; 82 | }); 83 | }); 84 | 85 | assertThrows( 86 | () => evaluate(() => reject(new Error("boom!"))), 87 | Error, 88 | "boom!", 89 | ); 90 | }); 91 | }); 92 | --------------------------------------------------------------------------------