├── .github └── workflows │ ├── branches.yml │ └── tags.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── html-response.ts ├── html.ts ├── index.ts ├── scripts └── build_npm.ts ├── test └── index.test.ts └── text-encoder-stream-polyfill.ts /.github/workflows/branches.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: v1.x 19 | - run: deno test ./test 20 | -------------------------------------------------------------------------------- /.github/workflows/tags.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - v* 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: v1.x 19 | - run: deno test ./test 20 | 21 | publish-npm: 22 | needs: test 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: denoland/setup-deno@v1 27 | with: 28 | deno-version: v1.x 29 | - uses: actions/setup-node@v1 30 | with: 31 | node-version: 14 32 | registry-url: https://registry.npmjs.org/ 33 | - uses: pnpm/action-setup@v2 34 | with: 35 | version: 6 36 | run_install: false 37 | - run: deno run -A ./scripts/build_npm.ts 38 | - run: cd ./npm && npm publish 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /npm -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true 5 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2020 Florian Klampfer (https://qwtel.com/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Worker HTML 2 | 3 | [HTML templating](#html-templating) and [streaming response](#streaming-responses) library for [Worker Runtimes](https://workers.js.org) such as Cloudflare Workers. 4 | 5 | 6 | ## HTML Templating 7 | 8 | Templating is done purely in JavaScript using tagged template strings, inspired by [hyperHTML](https://github.com/WebReflection/hyperhtml) and [lit-html](https://github.com/polymer/lit-html). 9 | 10 | This library is using tagged template strings to create _streaming response bodies_ on the fly, 11 | using no special template syntax and giving you the full power of JS for composition. 12 | 13 | ### Examples 14 | String interpolation works just like regular template strings, 15 | but all content is sanitized by default: 16 | 17 | ```ts 18 | const helloWorld = 'Hello World!'; 19 | const h1El = html`

${helloWorld}

`; 20 | ``` 21 | 22 | What is known as "partials" in string-based templating libraries are just functions here: 23 | 24 | ```ts 25 | const timeEl = (ts = new Date()) => html` 26 | 27 | `; 28 | ``` 29 | 30 | What is known as "layouts" are just functions as well: 31 | 32 | ```ts 33 | const baseLayout = (title: string, content: HTMLContent) => html` 34 | 35 | 36 | 37 | 38 | ${title} 39 | 40 | ${content} 41 | 42 | `; 43 | ``` 44 | 45 | Layouts can "inherit" from each other, again using just functions: 46 | 47 | ```ts 48 | const pageLayout = (title: string, content: HTMLContent) => baseLayout(title, html` 49 |
50 | ${content} 51 |
Powered by @worker-tools/html
52 |
53 | `); 54 | ``` 55 | 56 | Many more features of string-based templating libraries can be replicated using functions. 57 | Most satisfying should be the use of `map` to replace a whole host of custom looping syntax: 58 | 59 | ```ts 60 | html``; 61 | ``` 62 | 63 | Putting it all together: 64 | 65 | ```ts 66 | function handleRequest(event: FetchEvent) { 67 | const page = pageLayout(helloWorld, html` 68 | ${h1El} 69 |

The current time is ${timeEl()}.

70 | 71 | `)); 72 | 73 | return new HTMLResponse(page); 74 | } 75 | 76 | self.addEventListener('fetch', ev => ev.respondWith(handleRequest(ev))); 77 | ``` 78 | 79 | Note that this works regardless of worker runtime: Cloudflare Workers, Service Workers in the browser, and hopefully other [Worker Runtimes](https://workers.js.org) that have yet to be implemented. 80 | 81 | ### Tooling 82 | Since the use of tagged string literals for HTML is not new (see hyperHTML, lit-html), there exists tooling for syntax highlighting, such as [`lit-html` in VSCode](https://marketplace.visualstudio.com/items?itemName=bierner.lit-html). 83 | 84 | 85 | ## Streaming Responses 86 | 87 | As a side effect of this approach, responses are streams by default. This means you can use async data, without delaying sending the headers and HTML content. 88 | 89 | In the example below, everything up to and including `

The current time is` will be sent immediately, with the reset sent after the API request completes: 90 | 91 | ```ts 92 | function handleRequest(event: FetchEvent) { 93 | // NOTE: No `await` here! 94 | const timeElPromise = fetch('https://time.api/now') 95 | .then(r => r.text()) 96 | .then(t => timeEl(new Date(t))); 97 | 98 | return new HTMLResponse(pageLayout('Hello World!', html` 99 |

Hello World!

100 |

The current time is ${timeElPromise}.

101 | `)); 102 | } 103 | ``` 104 | 105 | While there's ways around the lack of async/await in the above example (namely IIAFEs), @worker-tools/html supports passing async functions as html content directly: 106 | 107 | ```ts 108 | function handleRequest(event: FetchEvent) { 109 | return new HTMLResponse(pageLayout('Hello World!', html` 110 |

Hello World!

111 | ${async () => { 112 | const timeStamp = new Date( 113 | await fetch('https://time.api/now').then(r => r.text()) 114 | ); 115 | return html`

The current time is ${timeEl(timeStamp)}.

` 116 | }} 117 | `)); 118 | } 119 | ``` 120 | 121 | Note that there are some subtle differences compared to the earlier examples. 122 | - The initial response will include headers and html up to and including `

Hello World!

` 123 | - The time API request will not be sent until the headers and html up to and including `

Hello World!

` have hit the wire. 124 | 125 | These follow from the way async/await works, so shouldn't be too surprising to those already familiar with common async/await pitfalls. 126 | 127 | If for any reason you don't want to use streaming response bodies, you can use the `BufferedHTMLResponse` instead, which will buffer the entire body before releasing it to the network. 128 | 129 | ## See Other 130 | You can combine this library with tools from the [Worker Tools family](https://workers.tools) such as `@worker-tools/response-creators`: 131 | 132 | ```ts 133 | import { internalServerError } from '@worker-tools/response-creators'; 134 | 135 | function handleRequest(event: FetchEvent) { 136 | return new HTMLResponse( 137 | pageLayout('Ooops', html`

Something went wrong

`), 138 | internalServerError(), 139 | ); 140 | } 141 | ``` 142 | 143 | You can also see the [Worker News source code](https://github.com/worker-tools/worker-news) for an example of how to build an entire web app on the edge using Worker HTML. 144 | 145 | Finally, you can read [The Joys and Perils of Writing Plain Old Web Apps](https://qwtel.com/posts/software/the-joys-and-perils-of-writing-plain-old-web-apps/) for a personal account of building web apps in a Web 2.0 way. 146 | 147 |
148 | 149 | -------- 150 | 151 |
152 | 153 |

154 |

This module is part of the Worker Tools collection
⁕ 155 | 156 | [Worker Tools](https://workers.tools) are a collection of TypeScript libraries for writing web servers in [Worker Runtimes](https://workers.js.org) such as Cloudflare Workers, Deno Deploy and Service Workers in the browser. 157 | 158 | If you liked this module, you might also like: 159 | 160 | - 🧭 [__Worker Router__][router] --- Complete routing solution that works across CF Workers, Deno and Service Workers 161 | - 🔋 [__Worker Middleware__][middleware] --- A suite of standalone HTTP server-side middleware with TypeScript support 162 | - 📄 [__Worker HTML__][html] --- HTML templating and streaming response library 163 | - 📦 [__Storage Area__][kv-storage] --- Key-value store abstraction across [Cloudflare KV][cloudflare-kv-storage], [Deno][deno-kv-storage] and browsers. 164 | - 🆗 [__Response Creators__][response-creators] --- Factory functions for responses with pre-filled status and status text 165 | - 🎏 [__Stream Response__][stream-response] --- Use async generators to build streaming responses for SSE, etc... 166 | - 🥏 [__JSON Fetch__][json-fetch] --- Drop-in replacements for Fetch API classes with first class support for JSON. 167 | - 🦑 [__JSON Stream__][json-stream] --- Streaming JSON parser/stingifier with first class support for web streams. 168 | 169 | Worker Tools also includes a number of polyfills that help bridge the gap between Worker Runtimes: 170 | - ✏️ [__HTML Rewriter__][html-rewriter] --- Cloudflare's HTML Rewriter for use in Deno, browsers, etc... 171 | - 📍 [__Location Polyfill__][location-polyfill] --- A `Location` polyfill for Cloudflare Workers. 172 | - 🦕 [__Deno Fetch Event Adapter__][deno-fetch-event-adapter] --- Dispatches global `fetch` events using Deno’s native HTTP server. 173 | 174 | [router]: https://workers.tools/router 175 | [middleware]: https://workers.tools/middleware 176 | [html]: https://workers.tools/html 177 | [kv-storage]: https://workers.tools/kv-storage 178 | [cloudflare-kv-storage]: https://workers.tools/cloudflare-kv-storage 179 | [deno-kv-storage]: https://workers.tools/deno-kv-storage 180 | [kv-storage-polyfill]: https://workers.tools/kv-storage-polyfill 181 | [response-creators]: https://workers.tools/response-creators 182 | [stream-response]: https://workers.tools/stream-response 183 | [json-fetch]: https://workers.tools/json-fetch 184 | [json-stream]: https://workers.tools/json-stream 185 | [request-cookie-store]: https://workers.tools/request-cookie-store 186 | [extendable-promise]: https://workers.tools/extendable-promise 187 | [html-rewriter]: https://workers.tools/html-rewriter 188 | [location-polyfill]: https://workers.tools/location-polyfill 189 | [deno-fetch-event-adapter]: https://workers.tools/deno-fetch-event-adapter 190 | 191 | Fore more visit [workers.tools](https://workers.tools). 192 | -------------------------------------------------------------------------------- /html-response.ts: -------------------------------------------------------------------------------- 1 | import { StreamResponse, BufferedStreamResponse } from 'https://ghuc.cc/worker-tools/stream-response/index.ts'; 2 | import { HTML } from './html.ts'; 3 | 4 | const CONTENT_TYPE = 'Content-Type' 5 | 6 | /** 7 | * TBD 8 | */ 9 | export class HTMLResponse extends StreamResponse { 10 | static contentType = 'text/html;charset=UTF-8'; 11 | 12 | constructor(html: HTML, { headers: _headers, ...init }: ResponseInit = {}) { 13 | const headers = new Headers(_headers); 14 | if (!headers.has(CONTENT_TYPE)) headers.set(CONTENT_TYPE, HTMLResponse.contentType); 15 | super(html, { headers, ...init }); 16 | } 17 | } 18 | 19 | /** 20 | * If for any reason you don't want to use streaming response bodies, 21 | * you can use this class instead, which will buffer the entire body before releasing it to the network. 22 | * Note that headers will still be sent immediately. 23 | */ 24 | export class BufferedHTMLResponse extends BufferedStreamResponse { 25 | static contentType = 'text/html;charset=UTF-8'; 26 | 27 | constructor(html: HTML, { headers: _headers, ...init }: ResponseInit = {}) { 28 | const headers = new Headers(_headers); 29 | if (!headers.has(CONTENT_TYPE)) headers.set(CONTENT_TYPE, BufferedHTMLResponse.contentType); 30 | super(html, { headers, ...init }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /html.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file ban-types no-explicit-any 2 | 3 | import { escapeHtml } from 'https://deno.land/x/escape_html@1.0.0/mod.ts'; 4 | 5 | type Primitive = undefined | boolean | number | string | bigint | symbol; 6 | type Callable = T | (() => T); 7 | 8 | export type Unpackable = 9 | | T 10 | | Iterable 11 | | Iterable> // isn't this the same as an async 0iterable? 12 | | Promise 13 | | Promise> 14 | | Promise>> 15 | | AsyncIterable 16 | | AsyncIterable> 17 | | AsyncIterable>> 18 | | Promise> 19 | | Promise>> 20 | | Promise>>> 21 | 22 | export type Renderable = null | Exclude | HTML | UnsafeHTML | Fallback; 23 | export type HTMLContentStatic = Unpackable; 24 | export type HTMLContent = Callable; 25 | 26 | const isIterable = (x?: unknown): x is (object & Iterable) => 27 | typeof x === 'object' && x !== null && Symbol.iterator in x; 28 | 29 | const isAsyncIterable = (x?: unknown): x is (object & AsyncIterable) => 30 | typeof x === 'object' && x !== null && Symbol.asyncIterator in x; 31 | 32 | async function* unpackContent(content: HTMLContentStatic): AsyncIterableIterator { 33 | const x = await content; 34 | if (x == null || x === '' || x === false) { 35 | yield ''; 36 | } else if (x instanceof AbstractHTML) { 37 | yield* x; 38 | } else if (isIterable(x)) { 39 | for (const xi of x) { 40 | yield* unpackContent(xi); 41 | } 42 | } else if (isAsyncIterable(x)) { 43 | for await (const xi of x) { 44 | yield* unpackContent(xi); 45 | } 46 | } else { 47 | yield escapeHtml(x as string); 48 | } 49 | } 50 | 51 | async function* unpack(content: HTMLContent): AsyncIterableIterator { 52 | try { 53 | yield* unpackContent(typeof content === 'function' ? content() : content); 54 | } catch (err) { 55 | if (err instanceof AbstractHTML) yield* err; 56 | else throw err; 57 | } 58 | } 59 | 60 | export abstract class AbstractHTML { 61 | abstract [Symbol.asyncIterator](): AsyncIterableIterator; 62 | } 63 | 64 | export class HTML extends AbstractHTML { 65 | #strings: TemplateStringsArray; 66 | #args: HTMLContent[]; 67 | 68 | constructor(strings: TemplateStringsArray, args: HTMLContent[]) { 69 | super(); 70 | this.#strings = strings; 71 | this.#args = args; 72 | } 73 | 74 | // async *[Symbol.asyncIterator]() { 75 | // return aInterleaveFlattenSecond(this.strings, map(unpack)(this.args)); 76 | // } 77 | async *[Symbol.asyncIterator](): AsyncIterableIterator { 78 | const stringsIt = this.#strings[Symbol.iterator](); 79 | const argsIt = this.#args[Symbol.iterator](); 80 | while (true) { 81 | const { done: stringDone, value: string } = stringsIt.next() as IteratorYieldResult; 82 | if (stringDone) break; 83 | else yield string; 84 | 85 | const { done: argDone, value: arg } = argsIt.next() as IteratorYieldResult; 86 | if (argDone) break; 87 | else yield* unpack(arg); 88 | } 89 | const { done: stringDone, value: string } = stringsIt.next() as IteratorYieldResult; 90 | if (stringDone) return; 91 | else yield string; 92 | } 93 | } 94 | 95 | export class UnsafeHTML extends AbstractHTML { 96 | #value: string; 97 | constructor(value: string) { super(); this.#value = value || '' } 98 | async *[Symbol.asyncIterator]() { yield this.#value } 99 | toString() { return this.#value } 100 | toJSON() { return this.#value } 101 | } 102 | 103 | export class Fallback extends AbstractHTML { 104 | #content: HTMLContent; 105 | #fallback: HTML | ((e: any) => HTML); 106 | 107 | constructor(content: HTMLContent, fallback: HTML | ((e: any) => HTML)) { 108 | super(); 109 | this.#content = content; 110 | this.#fallback = fallback; 111 | } 112 | 113 | async *[Symbol.asyncIterator]() { 114 | try { 115 | yield* unpack(this.#content) 116 | } catch (e) { 117 | yield* typeof this.#fallback === 'function' 118 | ? this.#fallback(e) 119 | : this.#fallback 120 | } 121 | } 122 | } 123 | 124 | export function html(strings: TemplateStringsArray, ...args: HTMLContent[]): HTML; 125 | export function html(strings: TemplateStringsArray, ...args: any[]): HTML; 126 | export function html(strings: TemplateStringsArray, ...args: HTMLContent[]) { 127 | return new HTML(strings, args); 128 | } 129 | 130 | // For the purpose of generating strings, there is no difference between html and css 131 | // so we can export this alias here to help with syntax highlighting and avoid confusion. 132 | export { html as css, html as js } 133 | 134 | export function fallback(content: HTMLContent, fallback: HTML | ((e: any) => HTML)): Fallback; 135 | export function fallback(content: any, fallback: HTML | ((e: any) => HTML)): Fallback; 136 | export function fallback(content: HTMLContent, fallback: HTML | ((e: any) => HTML)) { 137 | return new Fallback(content, fallback); 138 | } 139 | 140 | export function unsafeHTML(content: string) { 141 | return new UnsafeHTML(content); 142 | } 143 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './html.ts'; 2 | export * from './html-response.ts'; 3 | -------------------------------------------------------------------------------- /scripts/build_npm.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run --allow-read --allow-write=./,/Users/qwtel/Library/Caches/deno --allow-net --allow-env=HOME,DENO_AUTH_TOKENS,DENO_DIR --allow-run=git,pnpm 2 | 3 | import { basename, extname } from "https://deno.land/std@0.133.0/path/mod.ts"; 4 | import { build, emptyDir } from "https://deno.land/x/dnt/mod.ts"; 5 | 6 | import { 7 | copyMdFiles, mkPackage, 8 | } from 'https://gist.githubusercontent.com/qwtel/ecf0c3ba7069a127b3d144afc06952f5/raw/latest-version.ts' 9 | 10 | await emptyDir("./npm"); 11 | 12 | const name = basename(Deno.cwd()) 13 | 14 | await build({ 15 | entryPoints: ["./index.ts", { 16 | name: './text-encoder-stream-polyfill', 17 | path: './text-encoder-stream-polyfill.ts', 18 | }], 19 | outDir: "./npm", 20 | shims: {}, 21 | test: false, 22 | package: await mkPackage(name), 23 | declaration: true, 24 | packageManager: 'pnpm', 25 | compilerOptions: { 26 | sourceMap: true, 27 | target: 'ES2019' 28 | }, 29 | mappings: { 30 | "https://ghuc.cc/worker-tools/stream-response/index.ts": { 31 | name: "@worker-tools/stream-response", 32 | version: "latest", 33 | }, 34 | }, 35 | }); 36 | 37 | // post build steps 38 | await copyMdFiles(); 39 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import 'https://gist.githubusercontent.com/qwtel/b14f0f81e3a96189f7771f83ee113f64/raw/TestRequest.ts' 2 | import { 3 | assert, 4 | assertExists, 5 | assertEquals, 6 | assertStrictEquals, 7 | assertStringIncludes, 8 | assertThrows, 9 | assertRejects, 10 | assertArrayIncludes, 11 | } from 'https://deno.land/std@0.133.0/testing/asserts.ts' 12 | const { test } = Deno; 13 | 14 | import { html, HTMLResponse } from '../index.ts' 15 | 16 | test('exists', () => { 17 | assertExists(html) 18 | assertExists(HTMLResponse) 19 | }) 20 | 21 | test('exists II', () => { 22 | assertExists(html`

`) 23 | assertExists(new HTMLResponse(html`
`)) 24 | }) 25 | 26 | test('stringify', async () => { 27 | assertEquals(await new HTMLResponse(html`
`).text(), '
') 28 | }) 29 | 30 | test('headers', () => { 31 | assertStringIncludes(new HTMLResponse(html`
`).headers.get('content-type')!, 'text/html') 32 | }) 33 | 34 | test('escaping', async () => { 35 | assertEquals( 36 | await new HTMLResponse(html`
${'
'}
`).text(), 37 | '
<div></div>
' 38 | ) 39 | }) 40 | 41 | test('async functions as values', async () => { 42 | assertEquals( 43 | await new HTMLResponse(html`
${async () => { 44 | await new Promise(r => setTimeout(r, 10)); 45 | return html`
` 46 | }}
`).text(), 47 | '
' 48 | ) 49 | }) 50 | 51 | test('promises as as values', async () => { 52 | assertEquals(await new HTMLResponse(html`
${(async () => { 53 | await new Promise(r => setTimeout(r, 10)); 54 | return html`
` 55 | })()}
`).text(), '
') 56 | }) 57 | 58 | const timeout = (n: number) => new Promise(r => setTimeout(r, n)) 59 | 60 | test('async generator functions as values', async () => { 61 | assertEquals(await new HTMLResponse(html``).text(), '') 67 | }) 68 | 69 | test('async generators as values', async () => { 70 | assertEquals(await new HTMLResponse(html``).text(), '') 76 | }) 77 | 78 | import { unsafeHTML } from '../index.ts' 79 | 80 | test('unsafe html', async () => { 81 | assertEquals( 82 | await new HTMLResponse(html`
${unsafeHTML('
')}
`).text(), 83 | '
' 84 | ) 85 | }) 86 | 87 | test('multiple interleaved values', async () => { 88 | assertEquals( 89 | await new HTMLResponse(html`
${html``}
${html``}
`).text(), 90 | '

' 91 | ) 92 | }) 93 | 94 | test('promises of lists as values', async () => { 95 | assertEquals(await new HTMLResponse(html``).text(), '') 99 | }) 100 | 101 | test('promises of async generators as values', async () => { 102 | assertEquals(await new HTMLResponse(html``).text(), '') 111 | }) 112 | 113 | import { fallback } from '../index.ts' 114 | 115 | test('fallback values', async () => { 116 | assertEquals( 117 | await new HTMLResponse(html`
${ 118 | fallback(html`
${() => { throw Error() }}
`, html`An error occurred`) 119 | }
`).text(), 120 | '
An error occurred
') 121 | }) 122 | 123 | test('fallback functions', async () => { 124 | assertEquals( 125 | await new HTMLResponse(html`
${ 126 | fallback(html`
${() => { throw Error('foo') }}
`, e => html`An error occurred: ${e.message}`) 127 | }
`).text(), 128 | '
An error occurred: foo
') 129 | }) 130 | -------------------------------------------------------------------------------- /text-encoder-stream-polyfill.ts: -------------------------------------------------------------------------------- 1 | if (!('TextEncoderStream' in self)) { 2 | class TES { 3 | encoder!: TextEncoder; 4 | start() { this.encoder = new TextEncoder() } 5 | transform(chunk: string, controller: TransformStreamDefaultController) { 6 | controller.enqueue(this.encoder.encode(chunk)) 7 | } 8 | } 9 | 10 | class JSTextEncoderStream extends TransformStream { 11 | #t: TES; 12 | constructor() { 13 | const t = new TES(); 14 | super(t); 15 | this.#t = t; 16 | } 17 | get encoding() { return this.#t.encoder.encoding } 18 | } 19 | 20 | Object.defineProperty(self, 'TextEncoderStream', { 21 | configurable: false, 22 | enumerable: false, 23 | writable: false, 24 | value: JSTextEncoderStream, 25 | }); 26 | } 27 | 28 | if (!('TextDecoderStream' in self)) { 29 | class TDS { 30 | decoder!: TextDecoder; 31 | encoding: string; 32 | options: TextDecoderOptions; 33 | constructor(encoding: string, options: TextDecoderOptions) { 34 | this.encoding = encoding; 35 | this.options = options; 36 | } 37 | start() { this.decoder = new TextDecoder(this.encoding, this.options) } 38 | transform(chunk: Uint8Array, controller: TransformStreamDefaultController) { 39 | controller.enqueue(this.decoder.decode(chunk, { stream: true })) 40 | } 41 | } 42 | 43 | class JSTextDecoderStream extends TransformStream { 44 | #t: TDS; 45 | constructor(encoding = 'utf-8', { ...options } = {}) { 46 | const t = new TDS(encoding, options); 47 | super(t); 48 | this.#t = t; 49 | } 50 | get encoding() { return this.#t.decoder.encoding } 51 | get fatal() { return this.#t.decoder.fatal } 52 | get ignoreBOM() { return this.#t.decoder.ignoreBOM } 53 | } 54 | 55 | Object.defineProperty(self, 'TextDecoderStream', { 56 | configurable: false, 57 | enumerable: false, 58 | writable: false, 59 | value: JSTextDecoderStream, 60 | }); 61 | } 62 | 63 | export {} --------------------------------------------------------------------------------