├── .eslintrc ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── deno.jsonc ├── import-map.json ├── package.json ├── pnpm-lock.yaml ├── public ├── app.webmanifest ├── darkbluearrow.png ├── darkbluearrow2x.png ├── darky18.png ├── favicon.ico ├── grayarrow.gif ├── grayarrow2x.gif ├── hn.js ├── icon_1024_mac.png ├── icon_1024_maskable.jpg ├── icon_1024_win.webp ├── new.png ├── news.css ├── s.gif ├── y18.gif └── y18.png ├── src ├── api │ ├── .gitignore │ ├── dom-api.ts │ ├── firebase.ts │ ├── index.ts │ ├── interface.ts │ ├── iter.ts │ ├── make-api.ts │ ├── rest-api.ts │ ├── rewrite-content.ts │ └── sw-api.ts ├── entry │ ├── cf.ts │ ├── deno-globals.ts │ ├── deno.ts │ ├── globals.ts │ ├── sw-def.ts │ └── sw.ts ├── location.ts ├── router.ts ├── routes │ ├── assets.ts │ ├── components.ts │ ├── index.ts │ ├── item.ts │ ├── manifest-handler.js │ ├── news.ts │ ├── threads.ts │ └── user.ts ├── stubs │ ├── device-detector.ts │ ├── firebase.ts │ ├── linkedom.ts │ └── perf_hooks.js └── vendor │ ├── aggregate-error.ts │ ├── async-queue.ts │ ├── awaited-values.ts │ ├── common-types.ts │ ├── custom-event-polyfill.ts │ ├── enumerable.ts │ ├── map-append.ts │ └── unsettle.ts ├── tsconfig.json ├── tsconfig.sw.json ├── typings ├── blockies.d.ts ├── global.d.ts └── negotiated.d.ts ├── worker-news.jpg └── wrangler.toml /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "@typescript-eslint/ban-ts-comment": "off" 4 | } 5 | } -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | id-token: write 10 | contents: read 11 | 12 | jobs: 13 | deploy-cf: 14 | runs-on: ubuntu-latest 15 | name: Deploy Cloudflare 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v3 19 | with: { node-version: 16 } 20 | - uses: pnpm/action-setup@v2 21 | with: { version: 7, run_install: false } 22 | - run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 23 | id: pnpm-cache 24 | - uses: actions/cache@v3 25 | with: 26 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 27 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 28 | restore-keys: | 29 | ${{ runner.os }}-pnpm-store- 30 | - run: pnpm install 31 | - uses: cloudflare/wrangler-action@2.0.0 32 | with: 33 | apiToken: ${{ secrets.CF_API_TOKEN }} 34 | environment: production 35 | - name: Fix permissions borked by cloudflare/wrangler-action 36 | run: sudo chown -R $(id -un):$(id -gn) ${{ github.workspace }} 37 | deploy-deno: 38 | runs-on: ubuntu-latest 39 | name: Deploy Deno 40 | steps: 41 | - uses: actions/checkout@v2 42 | - uses: actions/setup-node@v3 43 | with: { node-version: 16 } 44 | - uses: pnpm/action-setup@v2 45 | with: { version: 7, run_install: false } 46 | - run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 47 | id: pnpm-cache 48 | - uses: actions/cache@v3 49 | with: 50 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 51 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 52 | restore-keys: | 53 | ${{ runner.os }}-pnpm-store- 54 | - run: pnpm install 55 | - run: rm -r node_modules 56 | - uses: denoland/deployctl@v1 57 | with: 58 | project: worker-news 59 | entrypoint: src/entry/deno.ts 60 | import-map: import-map.json 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dist 3 | **/*.rs.bk 4 | Cargo.lock 5 | bin/ 6 | pkg/ 7 | wasm-pack.log 8 | worker/ 9 | temp/ 10 | node_modules/ 11 | .cargo-ok 12 | cert.pem 13 | *.sqlite 14 | /sw.js* 15 | /tmp 16 | /public/sw.js* 17 | /import-map.dev.json 18 | _* -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.importMap": "import-map.json", 4 | "deno.config": "deno.jsonc" 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Worker News 2 | 3 | A drop in replacement for Hacker News with support for dark mode, quotes in comments, user identicons and submission favicons. 4 | 5 | [![Screenshot](./worker-news.jpg)](https://worker-news.deno.dev) 6 | 7 | ## What's cool about this? 8 | - Developed against a generic [Worker Runtime](https://workers.js.org) so that it can run on Cloudflare Workers, Deno Deploy and even the browser's Service Worker. 9 | - Can be installed + offline support: Same code that runs on the edge powers the PWA. 10 | - Everything is stream/async generator-based: API calls, HTML scraping, HTML responses, even JSON stringification and parsing. 11 | - Supports 3 API backends: HTML scraping from news.ycombinator.com, HTTP requests to HN API and HN API via Firebase. 12 | - Built using my own web framework, [Worker Tools](https://workers.tools), which is specifically developed to run across CF Workers, Deno and Service Workers. 13 | 14 | ## Notes 15 | - The PWA is optional. This app is just HTML streamed from the edge. If the PWA is installed, it is JSON streamed from the edge + HTML streamed from the Service Worker. 16 | - PWA requires latest browsers. FF only works when `TransformStream`s are enabled in in about:config. 17 | - A side effect of this approach is very low TTFB. This version feels faster than HN itself, even when it might be slower. 18 | - Not everything that HN supports is supported by the HN API. The HN API is missing many properties, such as # of descendants of a comment and comment quality (used to gray out downvoted comments). The HTML scraping API doesn't have this problem, but it quickly runs into a scrape shield, especially for infrequently requested sites. Works well when running on your own machine though (scrape shield seems to be more forgiving for new IPs). 19 | 20 | ## Development 21 | You can run the worker locally either via Wrangler, Node+Miniflare or Deno CLI. 22 | For Node/Wrangler it's best to install dependencies via `pnpm` (lockfile checked in), but npm or yarn is probably fine too. 23 | 24 | Then run `npm start` and open . 25 | If you have Wrangler 2.0 is installed, run `wrangler dev --local` instead. 26 | 27 | Deno users can simply run `deno task serve` and open . 28 | 29 | ### Note on Running on Cloudflare Workers 30 | While the app runs fine with Miniflare/`wrangler dev --local`, it works poorly when running on Cloudflare's edge network. 31 | The reason is that the HN API is not usable from CF Workers. Firebase is missing dependencies in the runtime (probably WebSocket or similar), 32 | while the REST API runs into CF Workers' 50 subrequest limit. 33 | This only leaves the DOM API (HTML scraping), which usually triggers HN's scrape shield... 34 | 35 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "importMap": "import-map.json", 3 | "tasks": { 4 | "clean": "rm -rf dist", 5 | "serve": "deno run --watch --allow-read=public --allow-net --allow-env=NODE_DEBUG,FIREBASE_DATABASE_EMULATOR_HOST --no-check=remote --location=http://localhost:8000 src/entry/deno.ts", 6 | "build-sw": "deno run -A 'https://deno.land/x/esbuild/mod.js' src/entry/sw.ts --bundle --sourcemap --target=es2020 --outfile=public/sw.js --define:global=self --tsconfig=tsconfig.sw.json --define:DEBUG=false --define:SW=true --minify", 7 | "watch-sw": "deno run -A 'https://deno.land/x/esbuild/mod.js' src/entry/sw.ts --bundle --sourcemap --target=es2020 --outfile=public/sw.js --define:global=self --tsconfig=tsconfig.sw.json --define:DEBUG=true --define:SW=true --watch", 8 | }, 9 | "lint": { 10 | "files": { 11 | "include": [ 12 | "src/" 13 | ], 14 | }, 15 | "rules": { 16 | "tags": [ 17 | "recommended" 18 | ], 19 | "include": [], 20 | "exclude": [ 21 | "no-unused-vars", 22 | "no-explicit-any", 23 | "no-cond-assign", 24 | "no-extra-semi", 25 | ], 26 | }, 27 | }, 28 | } -------------------------------------------------------------------------------- /import-map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@worker-tools/router": "https://ghuc.cc/worker-tools/router/index.ts", 4 | "@worker-tools/middleware": "https://ghuc.cc/worker-tools/middleware/index.ts", 5 | "@worker-tools/html": "https://ghuc.cc/worker-tools/html/index.ts", 6 | "@worker-tools/json-stream": "https://ghuc.cc/worker-tools/json-stream/index.ts", 7 | "@worker-tools/json-fetch": "https://ghuc.cc/worker-tools/json-fetch/index.ts", 8 | "@worker-tools/stream-response": "https://ghuc.cc/worker-tools/stream-response/index.ts", 9 | "@worker-tools/resolvable-promise": "https://ghuc.cc/worker-tools/resolvable-promise/index.ts", 10 | "@worker-tools/response-creators": "https://ghuc.cc/worker-tools/response-creators/index.ts", 11 | "@worker-tools/html-rewriter/polyfill": "https://deno.land/x/html_rewriter/polyfill.ts", 12 | "@worker-tools/location-polyfill": "https://ghuc.cc/worker-tools/location-polyfill/index.ts", 13 | "device-detector-js": "https://cdn.skypack.dev/device-detector-js?dts", 14 | "ts-functional-pipe": "https://cdn.skypack.dev/ts-functional-pipe?dts", 15 | "linkedom": "https://esm.sh/linkedom", 16 | "firebase/app": "https://esm.sh/v78/firebase@9.8.1/app", 17 | "firebase/database": "https://esm.sh/v78/firebase@9.8.1/database", 18 | "date-fns": "https://cdn.skypack.dev/date-fns?dts", 19 | "@qwtel/blockies": "https://ghuc.cc/qwtel/blockies/src/blockies.mjs", 20 | "@qwtel/p-queue-browser": "https://cdn.skypack.dev/@qwtel/p-queue-browser?dts", 21 | "whatwg-stream-to-async-iter": "https://ghuc.cc/qwtel/whatwg-stream-to-async-iter/index.ts", 22 | "@cloudflare/kv-asset-handler": "https://cdn.skypack.dev/@cloudflare/kv-asset-handler?dts", 23 | "parse-domain": "https://cdn.skypack.dev/parse-domain?dts", 24 | "html-escaper": "https://cdn.skypack.dev/html-escaper?dts", 25 | "urlpattern-polyfill": "https://cdn.skypack.dev/urlpattern-polyfill?dts" 26 | } 27 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "edge-hn", 4 | "version": "0.1.0-pre.0", 5 | "main": "dist/worker.js", 6 | "scripts": { 7 | "postinstall": "npm run build", 8 | "start": "npm run serve", 9 | "build": "npm run build-sw && npm run build-cf", 10 | "build-cf": "esbuild src/entry/cf.ts --bundle --sourcemap --target=es2022 --outfile=dist/worker.js --define:global=self --tsconfig=tsconfig.json --define:DEBUG=false --define:SW=false --minify", 11 | "watch-cf": "esbuild src/entry/cf.ts --bundle --sourcemap --target=es2022 --outfile=dist/worker.js --define:global=self --tsconfig=tsconfig.json --define:DEBUG=true --define:SW=false --watch", 12 | "build-sw": "esbuild src/entry/sw.ts --bundle --sourcemap --target=es2020 --outfile=public/sw.js --define:global=self --tsconfig=tsconfig.sw.json --define:DEBUG=false --define:SW=true --minify", 13 | "watch-sw": "esbuild src/entry/sw.ts --bundle --sourcemap --target=es2020 --outfile=public/sw.js --define:global=self --tsconfig=tsconfig.sw.json --define:DEBUG=true --define:SW=true --watch", 14 | "serve": "miniflare --watch", 15 | "serve-cf": "wrangler dev --local", 16 | "serve-cf-remote": "wrangler dev", 17 | "clean": "rm -rf dist" 18 | }, 19 | "author": "Florian Klampfer ", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@cloudflare/workers-types": "^3.11.0", 23 | "@types/html-escaper": "^3.0.0", 24 | "@types/node": "^17.0.35", 25 | "esbuild": "^0.14.39", 26 | "miniflare": "^2.4.0", 27 | "typescript": "^4.7.2" 28 | }, 29 | "dependencies": { 30 | "@cloudflare/kv-asset-handler": "^0.2.0", 31 | "@qwtel/blockies": "github:qwtel/blockies", 32 | "@qwtel/p-queue-browser": "^6.6.2", 33 | "@stardazed/streams": "^3.1.0", 34 | "@stardazed/streams-compression": "^1.0.0", 35 | "@stardazed/streams-fetch-adapter": "^3.0.0", 36 | "@stardazed/streams-text-encoding": "^1.0.2", 37 | "@worker-tools/cloudflare-kv-storage": "^0.8.0", 38 | "@worker-tools/deno-fetch-event-adapter": "^1.0.5", 39 | "@worker-tools/deno-kv-storage": "^0.4.4", 40 | "@worker-tools/extendable-promise": "0.2.0-pre.10", 41 | "@worker-tools/html": "^2.0.0-pre.12", 42 | "@worker-tools/html-rewriter": "^0.1.0-pre.16", 43 | "@worker-tools/json-fetch": "2.1.0-pre.5", 44 | "@worker-tools/json-stream": "0.1.0-pre.12", 45 | "@worker-tools/location-polyfill": "^0.4.1", 46 | "@worker-tools/middleware": "^0.1.0-pre.24", 47 | "@worker-tools/resolvable-promise": "^0.2.0-pre.5", 48 | "@worker-tools/response-creators": "^1.0.8", 49 | "@worker-tools/router": "0.3.0-pre.0", 50 | "@worker-tools/stream-response": "^0.1.0-pre.3", 51 | "base64-encoding": "^0.14.3", 52 | "date-fns": "^2.28.0", 53 | "device-detector-js": "^3.0.3", 54 | "firebase": "^9.8.1", 55 | "html-escaper": "^3.0.3", 56 | "kv-storage-interface": "^0.2.0", 57 | "kv-storage-polyfill": "^2.0.0", 58 | "linkedom": "^0.14.9", 59 | "parse-domain": "^7.0.0", 60 | "ts-functional-pipe": "^3.1.2", 61 | "typed-array-utils": "^0.2.2", 62 | "typeson": "^7.0.2", 63 | "typeson-registry": "^3.0.0", 64 | "urlpattern-polyfill": "4", 65 | "uuid-class": "^0.12.3", 66 | "whatwg-stream-to-async-iter": "0.6.2" 67 | }, 68 | "repository": { 69 | "type": "git", 70 | "url": "git+https://github.com/worker-tools/edge-hn.git" 71 | }, 72 | "bugs": { 73 | "url": "https://github.com/worker-tools/edge-hn/issues" 74 | }, 75 | "homepage": "https://github.com/worker-tools/edge-hn#readme", 76 | "description": "" 77 | } 78 | -------------------------------------------------------------------------------- /public/app.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Worker News", 3 | "short_name": "Worker News", 4 | "start_url": ".", 5 | "display": "minimal-ui", 6 | "description": "A drop-in replacement for Hacker News", 7 | "background_color": "#fff", 8 | "color_scheme_dark": { 9 | "background_color": "#101114" 10 | }, 11 | "icons": [ 12 | { 13 | "__os": "Mac", 14 | "src": "icon_1024_mac.png", 15 | "type": "image/png", 16 | "sizes": "1024x1024" 17 | }, 18 | { 19 | "__os": "Windows", 20 | "src": "icon_1024_win.webp", 21 | "type": "image/webp", 22 | "sizes": "1024x1024" 23 | }, 24 | { 25 | "src": "icon_1024_maskable.jpg", 26 | "type": "image/jpg", 27 | "sizes": "1024x1024", 28 | "purpose": "maskable" 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /public/darkbluearrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/darkbluearrow.png -------------------------------------------------------------------------------- /public/darkbluearrow2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/darkbluearrow2x.png -------------------------------------------------------------------------------- /public/darky18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/darky18.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/favicon.ico -------------------------------------------------------------------------------- /public/grayarrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/grayarrow.gif -------------------------------------------------------------------------------- /public/grayarrow2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/grayarrow2x.gif -------------------------------------------------------------------------------- /public/hn.js: -------------------------------------------------------------------------------- 1 | function $(id) { return document.getElementById(id); } 2 | function byClass (el, cl) { return el ? el.getElementsByClassName(cl) : [] } 3 | function byTag (el, tg) { return el ? el.getElementsByTagName(tg) : [] } 4 | function allof (cl) { return byClass(document, cl) } 5 | function classes (el) { return (el && el.className && el.className.split(' ')) || []; } 6 | function hasClass (el, cl) { return afind(cl, classes(el)) } 7 | function addClass (el, cl) { if (el) { var a = classes(el); if (!afind(cl, a)) { a.unshift(cl); el.className = a.join(' ')}} } 8 | function remClass (el, cl) { if (el) { var a = classes(el); arem(a, cl); el.className = a.join(' ') } } 9 | function uptil (el, f) { if (el) return f(el) ? el : uptil (el.parentNode, f) } 10 | function upclass (el, cl) { return uptil(el, function (x) { return hasClass(x, cl) }) } 11 | function html (el) { return el ? el.innerHTML : null; } 12 | function attr (el, name) { return el.getAttribute(name) } 13 | function tonum (x) { var n = parseFloat(x); return isNaN(n) ? null : n } 14 | function remEl (el) { el.parentNode.removeChild(el) } 15 | function posf (f, a) { for (var i=0; i < a.length; i++) { if (f(a[i])) return i; } return -1; } 16 | function apos (x, a) { return (typeof x == 'function') ? posf(x,a) : Array.prototype.indexOf.call(a,x) } 17 | function afind (x, a) { var i = apos(x, a); return (i >= 0) ? a[i] : null; } 18 | function acut (a, m, n) { return Array.prototype.slice.call(a, m, n) } 19 | function aeach (fn, a) { return Array.prototype.forEach.call(a, fn) } 20 | function arem (a, x) { var i = apos(x, a); if (i >= 0) { a.splice(i, 1); } return a; } 21 | function alast (a) { return a[a.length - 1] } 22 | function vis(el, on) { if (el) { (on ? remClass : addClass)(el, 'nosee') } } 23 | function setshow (el, on) { (on ? remClass : addClass)(el, 'noshow') } 24 | function noshow (el) { setshow(el, false) } 25 | 26 | function ind (tr) { 27 | var el = byClass(tr, 'ind')[0]; 28 | return el ? tonum(attr(el, 'indent')) : null; 29 | } 30 | 31 | function vurl (id, how, auth, _goto) { 32 | return "vote?id=" + id + "&how=" + how + "&auth=" + auth + "&goto=" + _goto + "&js=t" 33 | } 34 | 35 | function vote (id, how, auth, _goto) { 36 | vis($('up_' + id), how == 'un'); 37 | vis($('down_' + id), how == 'un'); 38 | var unv = ''; 39 | if (how != 'un') { 40 | unv = " | " + 42 | (how == 'up' ? 'unvote' : 'undown') + "" 43 | } 44 | $('unv_' + id).innerHTML = unv; 45 | new Image().src = vurl(id, how, auth, _goto); 46 | } 47 | 48 | function kid1 (el) { 49 | while (el = el.nextElementSibling) { 50 | if (hasClass(el, 'comtr')) return el; 51 | } 52 | } 53 | 54 | function kidvis (tr, hide) { 55 | var n0 = ind(tr), n = ind(kid1(tr)), coll = false; 56 | if (n > n0) { 57 | while (tr = kid1(tr)) { 58 | if (ind(tr) <= n0) { 59 | break; 60 | } else if (hide) { 61 | setshow(tr, false); 62 | } else if (ind(tr) == n) { 63 | coll = hasClass(tr, 'coll'); 64 | setshow(tr, true); 65 | } else if (!coll) { 66 | setshow(tr, true); 67 | } 68 | } 69 | } 70 | } 71 | 72 | function toggle (id) { 73 | var tr = $(id), on = !hasClass(tr, 'coll'); 74 | commstate(tr, on); 75 | kidvis(tr, on); 76 | if ($('logout')) { 77 | new Image().src = 'collapse?id=' + id + (on ? '' : '&un=true'); 78 | } 79 | } 80 | 81 | function commstate (tr, coll) { 82 | (coll ? addClass : remClass)(tr, 'coll'); 83 | vis(byClass(tr, 'votelinks')[0], !coll); 84 | setshow(byClass(tr, 'comment')[0], !coll); 85 | var el = byClass(tr, 'togg')[0]; 86 | el.innerHTML = coll ? ('[' + attr(el, 'n') + ' more]') : '[–]'; 87 | } 88 | 89 | function onop () { return attr(byTag(document,'html')[0],'op') } 90 | 91 | function ranknum (el) { 92 | var s = html(el) || ""; 93 | var a = s.match(/[0-9]+/); 94 | if (a) { 95 | return tonum(a[0]); 96 | } 97 | } 98 | 99 | var n1 = ranknum(allof('rank')[0]) || 1; 100 | 101 | function newstory (pair) { 102 | if (pair) { 103 | var sp = alast(allof('spacer')); 104 | sp.insertAdjacentHTML('afterend', pair[0] + sp.outerHTML); 105 | fixranks(); 106 | if (onop() == 'newest') { 107 | var n = ranknum(alast(allof('rank'))); 108 | allof('morelink')[0].href = 'newest?next=' + pair[1] + '&n=' + (n + 1); 109 | } 110 | } 111 | } 112 | 113 | function fixranks () { 114 | var rks = allof('rank'); 115 | aeach(function (rk) { rk.innerHTML = (apos(rk,rks) + n1) + '.' }, rks); 116 | } 117 | 118 | function moreurl() { return allof('morelink')[0].href } 119 | function morenext () { return tonum(moreurl().split('next=')[1]) } 120 | 121 | function hidestory (el, id) { 122 | for (var i=0; i < 3; i++) { remEl($(id).nextSibling) } 123 | remEl($(id)); 124 | fixranks(); 125 | var next = (onop() == 'newest' && morenext()) ? ('&next=' + morenext()) : ''; 126 | var url = el.href.replace('hide', 'snip-story').replace('goto', 'onop'); 127 | fetch(url + next).then(r => r.json()).then(newstory); 128 | } 129 | 130 | function onclick (ev) { 131 | var el = upclass(ev.target, 'clicky'); 132 | if (el) { 133 | var u = new URL(el.href, location); 134 | var p = u.searchParams; 135 | if (u.pathname == '/vote') { 136 | vote(p.get('id'), p.get('how'), p.get('auth'), p.get('goto')); 137 | } else if (u.pathname == '/hide') { 138 | hidestory(el, p.get('id')); 139 | } else if (hasClass(el, 'togg')) { 140 | toggle(attr(el, 'id')); 141 | } else { 142 | $(u.hash.substring(1)).scrollIntoView({behavior: "smooth"}) 143 | } 144 | ev.stopPropagation(); 145 | ev.stopImmediatePropagation(); 146 | ev.preventDefault(); 147 | return false; 148 | } 149 | } 150 | 151 | document.addEventListener("click", onclick); 152 | 153 | function popitup(el, ev, width, height) { 154 | if (!ev.metaKey && !ev.ctrlKey) { 155 | ev.preventDefault(); 156 | var url = el.getAttribute('href'); 157 | var rect = el.getBoundingClientRect(); 158 | var left = window.screenX + rect.left; 159 | var top = window.screenY + rect.top; 160 | var newWindow = window.open(url,'name','left='+left+',top='+top+',width='+(width||250)+',height='+(height||100)); 161 | if (window.focus) { newWindow.focus() } 162 | return false; 163 | } 164 | } 165 | 166 | function submitit(el, ev) { 167 | var q = ev.clipboardData.getData("text/plain"); 168 | el.value = q; 169 | el.form.submit(); 170 | } -------------------------------------------------------------------------------- /public/icon_1024_mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/icon_1024_mac.png -------------------------------------------------------------------------------- /public/icon_1024_maskable.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/icon_1024_maskable.jpg -------------------------------------------------------------------------------- /public/icon_1024_win.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/icon_1024_win.webp -------------------------------------------------------------------------------- /public/new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/new.png -------------------------------------------------------------------------------- /public/news.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: dark light; 3 | 4 | --font: Verdana, Geneva, sans-serif; 5 | --blue-h: 227; 6 | --blue-s: 10%; 7 | 8 | --black: #000; 9 | --dark-grey: #222; 10 | --light-grey: #828282; 11 | --lightest-grey: #b8b8b8; 12 | --white: #fff; 13 | --orange: #f38020; 14 | --yellow: #e9bc4b; 15 | --beige: #f6f6ef; 16 | 17 | --darkest-blue: hsl(var(--blue-h), var(--blue-s), 7%); 18 | --dark-blue:hsl(var(--blue-h), var(--blue-s), 12%); 19 | --blue:hsl(var(--blue-h), var(--blue-s), 24%); 20 | --light-blue:hsl(var(--blue-h), var(--blue-s), 51%); 21 | 22 | --page-background: var(--white); 23 | --accent: var(--orange); 24 | --text: var(--light-grey); 25 | --text-strong: var(--black); 26 | --border: var(--dark-grey); 27 | --background: var(--beige); 28 | --input-background: var(--white); 29 | --input-border: var(--lightest-grey); 30 | 31 | --c5a: #5a5a5a; 32 | --c73: #737373; 33 | --c88: #888888; 34 | --c9c: #9c9c9c; 35 | --cae: #aeaeae; 36 | --cbe: #bebebe; 37 | --cce: #cecece; 38 | --cdd: #dddddd; 39 | 40 | --titlebar-color: #ee9b33; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --page-background: var(--darkest-blue); 46 | --accent: var(--blue); 47 | --text: var(--light-blue); 48 | --text-strong: var(--lightest-grey); 49 | --border: var(--light-blue); 50 | --background: var(--dark-blue); 51 | --input-border: var(--dark-blue); 52 | --input-background: var(--darkest-blue); 53 | --input-border: var(--accent); 54 | 55 | --c5a: hsl(var(--blue-h), var(--blue-s), 61%); 56 | --c73: hsl(var(--blue-h), var(--blue-s), 56%); 57 | --c88: hsl(var(--blue-h), var(--blue-s), 46%); 58 | --c9c: hsl(var(--blue-h), var(--blue-s), 41%); 59 | --cae: hsl(var(--blue-h), var(--blue-s), 36%); 60 | --cbe: hsl(var(--blue-h), var(--blue-s), 31%); 61 | --cce: hsl(var(--blue-h), var(--blue-s), 26%); 62 | --cdd: hsl(var(--blue-h), var(--blue-s), 21%); 63 | 64 | --titlebar-color: var(--accent); 65 | } 66 | 67 | :root input[type="text"], 68 | :root input[type='number'], 69 | :root textarea { 70 | background-color: var(--input-background); 71 | border: 1px solid var(--input-border); 72 | } 73 | } 74 | 75 | html { background-color: var(--page-background); scrollbar-gutter:stable; } 76 | 77 | body { font-family:var(--font); font-size:10pt; color:var(--text); } 78 | td { font-family:var(--font); font-size:10pt; color:var(--text); } 79 | 80 | .admin td { font-family:var(--font); font-size:8.5pt; color:var(--text-strong); } 81 | .subtext td { font-family:var(--font); font-size: 7pt; color:var(--text); } 82 | 83 | input { font-family:monospace; font-size:10pt; } 84 | textarea { font-family:monospace; font-size:10pt; resize:both; } 85 | blockquote { border-left: 4px solid var(--input-border); margin: 0; color: var(--text); margin-left: 1px; padding-left: 9px; } 86 | hr { border: none; border-bottom: 1px solid var(--input-border); margin: 11pt 0 } 87 | 88 | a:link { color:var(--text-strong); text-decoration:none; } 89 | a:visited { color:var(--text); text-decoration:none; } 90 | 91 | #hnmain { background-color: var(--background); width:85% } 92 | #header { background: var(--titlebar-color); } 93 | #border { background: var(--titlebar-color); } 94 | 95 | .default { font-family:var(--font); font-size: 10pt; color:var(--text); } 96 | .admin { font-family:var(--font); font-size:8.5pt; color:var(--text-strong); } 97 | .title { font-family:var(--font); font-size: 10pt; color:var(--text); overflow:hidden; } 98 | .subtext { font-family:var(--font); font-size: 7pt; color:var(--text); } 99 | .yclinks { font-family:var(--font); font-size: 8pt; color:var(--text); } 100 | .pagetop { font-family:var(--font); font-size: 10pt; color:var(--text-strong); line-height:12px; } 101 | .comhead { font-family:var(--font); font-size: 8pt; color:var(--text); } 102 | .comment { font-family:var(--font); font-size: 9pt; } 103 | .hnname { margin-right: 5px; } 104 | 105 | #hnmain { min-width: 796px; } 106 | 107 | .title a { word-break: break-word; } 108 | 109 | .comment a:link, .comment a:visited { text-decoration: underline; } 110 | .noshow { display: none; } 111 | .nosee { visibility: hidden; pointer-events: none; cursor: default } 112 | 113 | .c00, .c00 a:link { color:var(--text-strong); } 114 | .c5a, .c5a a:link, .c5a a:visited { color:var(--c5a); } 115 | .c73, .c73 a:link, .c73 a:visited { color:var(--c73); } 116 | .c82, .c82 a:link, .c82 a:visited { color:var(--text); } 117 | .c88, .c88 a:link, .c88 a:visited { color:var(--c88); } 118 | .c9c, .c9c a:link, .c9c a:visited { color:var(--c9c); } 119 | .cae, .cae a:link, .cae a:visited { color:var(--cae); } 120 | .cbe, .cbe a:link, .cbe a:visited { color:var(--cbe); } 121 | .cce, .cce a:link, .cce a:visited { color:var(--cce); } 122 | .cdd, .cdd a:link, .cdd a:visited { color:var(--cdd); } 123 | 124 | .pagetop a, .pagetop a:visited { color:var(--text-strong); } 125 | .topsel a:link, .topsel a:visited { color:#ffffff; } 126 | 127 | .subtext a:link, .subtext a:visited { color:var(--text); } 128 | .subtext a:hover { text-decoration:underline; } 129 | 130 | .comhead a:link, .subtext a:visited { color:var(--text); } 131 | .comhead a:hover { text-decoration:underline; } 132 | 133 | .hnmore a:link, a:visited { color:var(--text); } 134 | .hnmore { text-decoration:underline; } 135 | 136 | .default p, .default li, .default blockquote { margin-top: 8px; margin-bottom: 0px; } 137 | .commtext > :first-child { margin-top: 0 }; 138 | 139 | .pagebreak {page-break-before:always} 140 | 141 | pre { overflow: auto; padding: 2px; white-space: pre-wrap; overflow-wrap:anywhere; } 142 | pre:hover { overflow:auto } 143 | 144 | .votearrow { 145 | width: 10px; 146 | height: 10px; 147 | border: 0px; 148 | margin: 3px 2px 6px; 149 | background: url("grayarrow.gif") 150 | no-repeat; 151 | } 152 | 153 | @media (prefers-color-scheme: dark) { 154 | .votearrow { background-image: url("darkbluearrow.png"); } 155 | } 156 | 157 | .votelinks.nosee div.votearrow.rotate180 { 158 | display: none; 159 | } 160 | 161 | table.padtab td { padding:0px 10px } 162 | 163 | @media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min-device-pixel-ratio: 2) { 164 | .votearrow { background-size: 10px; background-image: url("grayarrow2x.gif"); } 165 | } 166 | @media only screen and (-webkit-min-device-pixel-ratio: 2) and (prefers-color-scheme: dark), only screen and (min-device-pixel-ratio: 2) and (prefers-color-scheme: dark) { 167 | .votearrow { background-image: url("darkbluearrow2x.png"); } 168 | } 169 | 170 | .rotate180 { 171 | -webkit-transform: rotate(180deg); /* Chrome and other webkit browsers */ 172 | -moz-transform: rotate(180deg); /* FF */ 173 | -o-transform: rotate(180deg); /* Opera */ 174 | -ms-transform: rotate(180deg); /* IE9 */ 175 | transform: rotate(180deg); /* W3C complaint browsers */ 176 | 177 | /* IE8 and below */ 178 | -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=-1, M12=0, M21=0, M22=-1, DX=0, DY=0, SizingMethod='auto expand')"; 179 | } 180 | 181 | /* mobile device */ 182 | @media only screen 183 | and (min-width : 300px) 184 | and (max-width : 750px) { 185 | #hnmain { width: 100%; min-width: 0; } 186 | body { padding: 0; margin: 0; width: 100%; -webkit-text-size-adjust: none; } 187 | td { height: inherit !important; } 188 | .title, .comment { font-size: inherit; } 189 | span.pagetop { display: block; margin: 3px 5px; font-size: 12px; line-height: normal } 190 | span.pagetop b { display: block; font-size: 15px; } 191 | table.comment-tree .comment a { display: inline-block; max-width: 200px; overflow: hidden; white-space: nowrap; 192 | text-overflow: ellipsis; vertical-align:top; } 193 | img[src='s.gif'][width='40'] { width: 12px; } 194 | img[src='s.gif'][width='80'] { width: 24px; } 195 | img[src='s.gif'][width='120'] { width: 36px; } 196 | img[src='s.gif'][width='160'] { width: 48px; } 197 | img[src='s.gif'][width='200'] { width: 60px; } 198 | img[src='s.gif'][width='240'] { width: 72px; } 199 | img[src='s.gif'][width='280'] { width: 84px; } 200 | img[src='s.gif'][width='320'] { width: 96px; } 201 | img[src='s.gif'][width='360'] { width: 108px; } 202 | img[src='s.gif'][width='400'] { width: 120px; } 203 | img[src='s.gif'][width='440'] { width: 132px; } 204 | img[src='s.gif'][width='480'] { width: 144px; } 205 | img[src='s.gif'][width='520'] { width: 156px; } 206 | img[src='s.gif'][width='560'] { width: 168px; } 207 | img[src='s.gif'][width='600'] { width: 180px; } 208 | img[src='s.gif'][width='640'] { width: 192px; } 209 | img[src='s.gif'][width='680'] { width: 204px; } 210 | img[src='s.gif'][width='720'] { width: 216px; } 211 | img[src='s.gif'][width='760'] { width: 228px; } 212 | img[src='s.gif'][width='800'] { width: 240px; } 213 | img[src='s.gif'][width='840'] { width: 252px; } 214 | .title { font-size: 11pt; line-height: 14pt; } 215 | .subtext { font-size: 9pt; } 216 | .itemlist { padding-right: 5px;} 217 | .votearrow { transform: scale(1.3,1.3); margin-right: 6px; } 218 | .votearrow.rotate180 { 219 | -webkit-transform: rotate(180deg) scale(1.3,1.3); /* Chrome and other webkit browsers */ 220 | -moz-transform: rotate(180deg) scale(1.3,1.3); /* FF */ 221 | -o-transform: rotate(180deg) scale(1.3,1.3); /* Opera */ 222 | -ms-transform: rotate(180deg) scale(1.3,1.3); /* IE9 */ 223 | transform: rotate(180deg) scale(1.3,1.3); /* W3C complaint browsers */ 224 | } 225 | .votelinks { min-width: 18px; } 226 | .votelinks a { display: block; margin-bottom: 9px; } 227 | input[type='text'], input[type='number'], textarea { font-size: 16px; width: 90%; } 228 | .favicon { width: 12px; height: 12px } 229 | } 230 | 231 | .comment { max-width: 1215px; overflow-wrap:anywhere; } 232 | 233 | 234 | 235 | @media only screen and (min-width : 300px) and (max-width : 389px) { 236 | .comment { max-width: 270px; overflow: hidden } 237 | } 238 | @media only screen and (min-width : 390px) and (max-width : 509px) { 239 | .comment { max-width: 350px; overflow: hidden } 240 | } 241 | @media only screen and (min-width : 510px) and (max-width : 599px) { 242 | .comment { max-width: 460px; overflow: hidden } 243 | } 244 | @media only screen and (min-width : 600px) and (max-width : 689px) { 245 | .comment { max-width: 540px; overflow: hidden } 246 | } 247 | @media only screen and (min-width : 690px) and (max-width : 809px) { 248 | .comment { max-width: 620px; overflow: hidden } 249 | } 250 | @media only screen and (min-width : 810px) and (max-width : 899px) { 251 | .comment { max-width: 730px; overflow: hidden } 252 | } 253 | @media only screen and (min-width : 900px) and (max-width : 1079px) { 254 | .comment { max-width: 810px; overflow: hidden } 255 | } 256 | @media only screen and (min-width : 1080px) and (max-width : 1169px) { 257 | .comment { max-width: 970px; overflow: hidden } 258 | } 259 | @media only screen and (min-width : 1170px) and (max-width : 1259px) { 260 | .comment { max-width: 1050px; overflow: hidden } 261 | } 262 | @media only screen and (min-width : 1260px) and (max-width : 1349px) { 263 | .comment { max-width: 1130px; overflow: hidden } 264 | } 265 | 266 | .sr-only { clip: rect(1px, 1px, 1px, 1px); -webkit-clip-path: inset(50%); clip-path: inset(50%); height: 1px; overflow: hidden; margin-left: -2px; padding: 0; display: inline-block; position: absolute; width: 1px; white-space: nowrap; } 267 | 268 | .identicon { position: relative; top: 1px } 269 | 270 | .athing { content-visibility: auto } 271 | .about { overflow:hidden; } 272 | .about > p:first-child { margin-top: 0 } 273 | 274 | @media (display-mode: minimal-ui), (display-mode: standalone), (display-mode: window-controls-overlay) { 275 | html { background-color: var(--background); } 276 | body { padding: 0; margin: 0; } 277 | #hnmain { width:100% } 278 | #header { position: sticky; top: 0; z-index: 2; padding: 0 5px } 279 | } 280 | 281 | @media (display-mode: standalone), (display-mode: window-controls-overlay) { 282 | #back, #reload { display: inline!important; } 283 | } 284 | 285 | @media (display-mode: window-controls-overlay) { 286 | #header { 287 | height: env(titlebar-area-height); 288 | padding-left: env(titlebar-area-x); 289 | padding-right: calc(100vw - env(titlebar-area-width) - env(titlebar-area-x) - 9px); 290 | -webkit-app-region: drag; 291 | app-region: drag; 292 | } 293 | .pagetop, a[href$="workers.tools"] { 294 | -webkit-app-region: no-drag; 295 | app-region: no-drag; 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /public/s.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/s.gif -------------------------------------------------------------------------------- /public/y18.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/y18.gif -------------------------------------------------------------------------------- /public/y18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/public/y18.png -------------------------------------------------------------------------------- /src/api/.gitignore: -------------------------------------------------------------------------------- 1 | _* -------------------------------------------------------------------------------- /src/api/dom-api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A web scraping (DOM-based) implementation of the Hacker News API. 3 | */ 4 | import { ParamsURL } from '@worker-tools/json-fetch'; 5 | import { ResolvablePromise } from '@worker-tools/resolvable-promise'; 6 | import { unescape } from 'html-escaper'; 7 | import { AsyncQueue } from '../vendor/async-queue.ts'; 8 | 9 | import { APost, AComment, APollOpt, Quality, Stories, AUser, StoriesParams, StoriesData, ThreadsData } from './interface.ts'; 10 | import { aMap } from './iter.ts'; 11 | import { blockquotify } from './rewrite-content.ts'; 12 | 13 | const HN = 'https://news.ycombinator.com' 14 | 15 | const storiesToPaths = { 16 | [Stories.TOP]: '/news', 17 | [Stories.NEW]: '/newest', 18 | [Stories.BEST]: '/best', 19 | [Stories.SHOW]: '/show', 20 | [Stories.SHOW_NEW]: '/shownew', 21 | [Stories.ASK]: '/ask', 22 | [Stories.JOB]: '/jobs', 23 | [Stories.USER]: '/submitted', 24 | [Stories.CLASSIC]: '/classic', 25 | [Stories.FROM]: '/from', 26 | [Stories.OFFLINE]: 'never', 27 | }; 28 | 29 | const extractId = (href: string | null) => Number(/item\?id=(\d+)/.exec(href ?? '')?.[1]); 30 | const elToTagOpen = (el: Element) => `<${el.tagName}${[...el.attributes].map(x => ` ${x[0]}="${x[1]}"`).join('')}>`; 31 | const endToTagClose = (endTag: EndTag) => ``; 32 | const elToDate = (el: Element) => new Date(unescape(el.getAttribute('title') ?? '') + '.000+00:00') 33 | const r2err = (body: Response) => { throw Error(`${body.status} ${body.statusText} ${body.url}`) } 34 | 35 | export async function stories(params: StoriesParams, type = Stories.TOP) { 36 | const pathname = storiesToPaths[type]; 37 | const url = new ParamsURL(pathname, params, HN); 38 | const body = await fetch(url.href) 39 | if (!body.ok) r2err(body) 40 | return storiesGenerator(body); 41 | } 42 | 43 | function storiesGenerator(response: Response): Promise { 44 | let post: Partial; 45 | 46 | const moreLink = new ResolvablePromise(); 47 | 48 | const iter = new AsyncQueue>() 49 | const rewriter = new HTMLRewriter() 50 | .on('.athing[id]', { 51 | element(el) { 52 | if (post) iter.enqueue(post); 53 | 54 | const id = Number(el.getAttribute('id')); 55 | post = { id, title: '', score: 0, by: '', descendants: 0, story: post?.story }; 56 | } 57 | }) 58 | .on('.athing[id] .titleline > a', { 59 | element(link) { post.url = unescape(link.getAttribute('href') ?? '') }, 60 | text({ text }) { post.title += text }, 61 | }) 62 | // // FIXME: concatenate text before parseInt jtbs.. 63 | .on('.subtext .subline .score', { 64 | text({ text }) { if (text?.trimStart().match(/^\d/)) post.score = parseInt(text, 10) } 65 | }) 66 | .on('.subtext .subline .hnuser', { 67 | text({ text }) { post.by += text } 68 | }) 69 | .on('.subtext .subline .age[title]', { 70 | element(el) { post.time = elToDate(el) } 71 | }) 72 | .on('.subtext .subline a[href^=item]', { 73 | text({ text }) { if (text?.trimStart().match(/^\d/)) post.descendants = parseInt(text, 10) } 74 | }) 75 | .on('.morelink[href]', { 76 | element(el) { moreLink.resolve(unescape(el.getAttribute('href') ?? '')) } 77 | }) 78 | .on('.yclinks', { 79 | element() { 80 | if (post) iter.enqueue(post) 81 | iter.return(); 82 | } 83 | }) 84 | 85 | rewriter.transform(response).body!.pipeTo(new WritableStream()) 86 | .then(() => iter.return()) 87 | .then(() => moreLink.resolve('')) 88 | .catch(err => iter.throw(err)); 89 | 90 | return Promise.resolve({ 91 | items: aMap(iter, post => { 92 | // console.log(response.url, iter.size) 93 | post.type = post.type || 'story'; 94 | if (!post.by) { // No users post this = job ads 95 | post.type = 'job'; 96 | } 97 | return post as APost; 98 | }), 99 | moreLink, 100 | }) 101 | } 102 | 103 | export async function comments(id: number, p?: number): Promise { 104 | const url = new ParamsURL('/item', { id, ...p ? { p } : {} }, HN).href; 105 | const body = await fetch(url) 106 | if (body.ok) return commentsGenerator(body); 107 | return r2err(body); 108 | } 109 | 110 | export async function threads(id: string, next?: number) { 111 | const url = new ParamsURL('/threads', { id, ...next ? { next } : {} }, HN).href; 112 | const body = await fetch(url) 113 | if (!body.ok) r2err(body); 114 | return threadsGenerator(body) 115 | } 116 | 117 | function scrapeComments(rewriter: HTMLRewriter, iter: AsyncQueue>, prefix = '') { 118 | let comment!: Partial; 119 | 120 | return rewriter 121 | .on(`${prefix} .athing.comtr[id]`, { 122 | element(thing) { 123 | if (comment) iter.enqueue(comment); 124 | const id = Number(thing.getAttribute('id')) 125 | comment = { id, type: 'comment', by: '', text: '', storyTitle: '' }; 126 | }, 127 | }) 128 | .on(`${prefix} .athing.comtr[id] .ind[indent]`, { 129 | element(el) { comment.level = Number(el.getAttribute('indent')) } 130 | }) 131 | .on(`${prefix} .athing.comtr[id] .hnuser`, { 132 | text({ text }) { comment.by += text } 133 | }) 134 | .on(`${prefix} .athing.comtr[id] .age[title]`, { 135 | element(el) { comment.time = elToDate(el) } 136 | }) 137 | .on(`${prefix} .athing.comtr[id] a.togg[id][n]`, { 138 | element(el) { comment.descendants = Number(el.getAttribute('n')) - 1 } 139 | }) 140 | .on(`${prefix} .athing.comtr[id] .onstory > a[href]`, { 141 | element(a) { comment.story = extractId(a.getAttribute('href')) }, 142 | text({ text }) { comment.storyTitle += text } 143 | }) 144 | .on(`${prefix} .athing.comtr[id] .commtext`, { 145 | element(el) { comment.quality = el.getAttribute('class')?.substr('commtext '.length).trim() as Quality }, 146 | text(chunk) { comment.text += chunk.text }, 147 | }) 148 | .on(`${prefix} .athing.comtr[id] .commtext *`, { 149 | element(el) { 150 | comment.text += elToTagOpen(el); 151 | el.onEndTag(end => { comment.text += endToTagClose(end) }) 152 | } 153 | }) 154 | .on(`${prefix} .athing.comtr[id] .comment .reply`, { 155 | element(el) { el.remove() } 156 | }) 157 | .on('.yclinks', { 158 | element() { 159 | if (comment) iter.enqueue(comment) 160 | iter.return() 161 | } 162 | }) 163 | } 164 | 165 | async function commentsGenerator(response: Response) { 166 | const post: Partial = { title: '', score: 0, by: '', descendants: 0, text: '', storyTitle: '', dead: true }; 167 | let pollOpt: Partial; 168 | 169 | const comm = new AsyncQueue>(); 170 | const opts = new AsyncQueue>(); 171 | 172 | const postPromise = new ResolvablePromise>(); 173 | const moreLink = new ResolvablePromise(); 174 | 175 | // console.log(response.status, response.url, ...response.headers, (await response.clone().arrayBuffer()).byteLength) 176 | 177 | const rewriter = new HTMLRewriter() 178 | .on('.fatitem > .athing[id]', { 179 | element(el) { post.id = Number(el.getAttribute('id')) }, 180 | }) 181 | .on('.fatitem > .athing[id] .title .titleline > a', { 182 | element(link) { post.url = unescape(link.getAttribute('href') ?? '') }, 183 | text({ text }) { post.title += text } 184 | }) 185 | // FIXME: concatenate text before parseInt jtbs.. 186 | .on('.fatitem .subtext .score', { 187 | text({ text }) { if (text?.trimStart().match(/^\d/)) post.score = parseInt(text, 10) } 188 | }) 189 | .on('.fatitem .subline .hnuser', { 190 | text({ text }) { post.by += text } 191 | }) 192 | .on('.fatitem .subline .age[title]', { 193 | element(el) { post.time = elToDate(el) } 194 | }) 195 | .on('.fatitem .subline > a[href^=item]', { 196 | text({ text }) { if (text?.trimStart().match(/^\d/)) post.descendants = parseInt(text, 10) } 197 | }) 198 | .on('.fatitem > tr:nth-child(4) > td:nth-child(2)', { 199 | text({ text }) { post.text += text } 200 | }) 201 | .on('.fatitem form, .comment-tree .reply a[href]', { 202 | element() { post.dead = false } 203 | }) 204 | // HACK: there's no good way to distinguish link and story submissions. 205 | // When it's a link, the reply form is in the same spot as the text is for a story submission, 206 | // so we just ignore all the form elements... 207 | .on('.fatitem > tr:nth-child(4) > td:nth-child(2) *:not(form):not(input):not(textarea):not(br)', { // HACK 208 | element(el) { 209 | post.text += elToTagOpen(el); 210 | el.onEndTag(end => { post.text += endToTagClose(end) }); 211 | } 212 | }) 213 | // Poll: item?id=30210378 214 | .on('.fatitem > tr:nth-child(6) tr.athing[id]', { 215 | element(el) { 216 | post.type = 'poll'; 217 | 218 | if (pollOpt) opts.enqueue(pollOpt) 219 | const id = Number(el.getAttribute('id')); 220 | pollOpt = { id, text: '', score: 0 }; 221 | } 222 | }) 223 | .on('.fatitem > tr:nth-child(6) tr.athing[id] > .comment > div', { 224 | text({ text }) { pollOpt.text += text; } 225 | }) 226 | .on('.fatitem > tr:nth-child(6) tr .comhead > .score ', { 227 | text({ text }) { if (text?.trimStart().match(/^\d/)) pollOpt.score = parseInt(text, 10) } 228 | }) 229 | .on('.fatitem .comhead > .hnuser', { 230 | text({ text }) { post.by += text } 231 | }) 232 | .on('.fatitem .comhead > .age[title]', { 233 | element(el) { post.time = elToDate(el) } 234 | }) 235 | .on('.fatitem .comhead > .navs > a[href^="item"]', { 236 | element(a) { post.parent = extractId(a.getAttribute('href')); } 237 | }) 238 | .on('.fatitem .comhead > .onstory > a[href]', { 239 | element(a) { post.story = extractId(a.getAttribute('href')) }, 240 | text({ text }) { (post.storyTitle) += text } 241 | }) 242 | .on('.fatitem .commtext', { 243 | element(el) { 244 | post.type = 'comment'; 245 | post.quality = el.getAttribute('class')?.substr('commtext '.length).trim() as Quality; 246 | }, 247 | text({ text }) { post.text += text } 248 | }) 249 | .on('.fatitem .commtext *', { 250 | element(el) { 251 | post.text += elToTagOpen(el); 252 | el.onEndTag(end => { post.text += endToTagClose(end) }) 253 | } 254 | }) 255 | .on('.comment-tree', { 256 | element() { postPromise.resolve(post) }, 257 | }) 258 | .on('a.morelink[href][rel="next"]', { 259 | element(el) { 260 | moreLink.resolve(unescape(el.getAttribute('href') ?? '')) 261 | }, 262 | }) 263 | 264 | scrapeComments(rewriter, comm, '.comment-tree'); 265 | 266 | rewriter.transform(response).body!.pipeTo(new WritableStream()) 267 | .then(() => (comm.return(), opts.return())) 268 | .then(() => moreLink.resolve('')) 269 | .catch(err => (comm.throw(err), opts.throw(err))); 270 | 271 | // wait for `post` to be populated 272 | await postPromise; 273 | 274 | if (post.text?.trim()) { 275 | post.text = await blockquotify('

' + post.text) 276 | } else delete post.text 277 | 278 | post.parts = aMap(opts, pollOpt => { 279 | pollOpt.poll = post.id!; 280 | pollOpt.by = post.by!; 281 | pollOpt.dead = post.dead; 282 | return fixPollOpt(pollOpt); 283 | }) 284 | 285 | post.kids = aMap(comm, comment => { 286 | comment.story = post.id; 287 | comment.dead = post.dead; 288 | return fixComment(comment) 289 | }); 290 | 291 | post.moreLink = moreLink; 292 | 293 | // console.log(post) 294 | 295 | return post as APost; 296 | }; 297 | 298 | async function fixComment(comment: Partial) { 299 | if (comment.text?.trim()) { 300 | comment.text = await blockquotify('

' + comment.text) 301 | } else { 302 | // FIXME: is this how it works?? 303 | // comment.deleted = true; 304 | // comment.text = ' [deleted] '; 305 | } 306 | return comment as AComment; 307 | } 308 | 309 | function fixPollOpt(pollOpt: Partial) { 310 | if (pollOpt.text) pollOpt.text = pollOpt.text.trim(); else delete pollOpt.text; 311 | return pollOpt as APollOpt; 312 | } 313 | 314 | function threadsGenerator(response: Response): Promise { 315 | const comm = new AsyncQueue>(); 316 | 317 | const moreLink = new ResolvablePromise(); 318 | const rewriter = new HTMLRewriter() 319 | .on('a.morelink[href][rel="next"]', { 320 | element(el) { moreLink.resolve(unescape(el.getAttribute('href') ?? '')) } 321 | }); 322 | 323 | scrapeComments(rewriter, comm, ''); 324 | 325 | rewriter.transform(response).body!.pipeTo(new WritableStream()) 326 | .then(() => comm.return()) 327 | .then(() => moreLink.resolve('')) 328 | .catch(e => comm.throw(e)); 329 | 330 | return Promise.resolve({ 331 | items: aMap(comm, comment => { 332 | return fixComment(comment) 333 | }), 334 | moreLink, 335 | }) 336 | }; 337 | 338 | export async function user(id: string): Promise { 339 | const url = new ParamsURL('user', { id }, HN); 340 | const response = await fetch(url.href); 341 | if (!response.ok) r2err(response); 342 | 343 | const user: Partial = { id, about: '', submitted: [] }; 344 | 345 | const rewriter = new HTMLRewriter() 346 | .on('tr.athing td[timestamp]', { 347 | element(el) { 348 | user.created = Number(el.getAttribute('timestamp')) 349 | } 350 | }) 351 | .on('tr > td > table[border="0"] > tr:nth-child(3) > td:nth-child(2)', { 352 | text({ text }) { if (text?.trimStart().match(/^\d/)) user.karma = parseInt(text, 10) } 353 | }) 354 | .on('tr > td > table[border="0"] > tr:nth-child(4) > td:nth-child(2)', { 355 | text({ text }) { user.about += text } 356 | }) 357 | .on('tr > td > table[border="0"] > tr:nth-child(4) > td:nth-child(2) *', { 358 | element(el) { 359 | user.about += elToTagOpen(el); 360 | el.onEndTag(end => { user.about += endToTagClose(end) }) 361 | } 362 | }) 363 | 364 | await rewriter.transform(response).body!.pipeTo(new WritableStream()) 365 | 366 | if (user.about?.trim()) user.about = '

' + user.about.trim(); 367 | 368 | return user as AUser; 369 | } 370 | -------------------------------------------------------------------------------- /src/api/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app'; 2 | import { getDatabase, ref, onValue } from 'firebase/database'; 3 | 4 | import { Stories, StoriesParams } from './interface.ts'; 5 | import * as mkAPI from './make-api.ts'; 6 | 7 | const db = getDatabase(initializeApp({ 8 | databaseURL: "https://hacker-news.firebaseio.com", 9 | })) 10 | const api = (href: string) => new Promise(res => onValue(ref(db, href), (snap: any) => res(snap.val()))); 11 | 12 | // FIXME: `next` pagination 13 | export function stories(params: StoriesParams, type = Stories.TOP) { 14 | return mkAPI.stories(api, params, type); 15 | } 16 | 17 | export function comments(id: number, p?: number) { 18 | return mkAPI.comments(api, id, p); 19 | } 20 | 21 | export function user(id: string) { 22 | return mkAPI.user(api, id); 23 | } 24 | 25 | export function threads(id: string, next?: number) { 26 | return mkAPI.threads(api, id, next); 27 | } -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interface.ts'; 2 | 3 | import * as domAPI from './dom-api.ts'; 4 | import * as swAPI from './sw-api.ts'; 5 | import * as restAPI from './rest-api.ts'; 6 | import * as fireAPI from './firebase.ts'; 7 | 8 | export const api = SW === true 9 | ? swAPI 10 | : 'Deno' in self 11 | ? fireAPI 12 | : domAPI 13 | -------------------------------------------------------------------------------- /src/api/interface.ts: -------------------------------------------------------------------------------- 1 | import { Awaitable } from "@worker-tools/router"; 2 | import { ForAwaitable } from "whatwg-stream-to-async-iter"; 3 | 4 | export enum Stories { 5 | TOP = 'news', 6 | NEW = 'newest', 7 | BEST = 'best', 8 | ASK = 'ask', 9 | SHOW = 'show', 10 | SHOW_NEW = 'shownew', 11 | JOB = 'jobs', 12 | USER = 'submitted', 13 | CLASSIC = 'classic', 14 | FROM = 'from', 15 | OFFLINE = 'offline', 16 | } 17 | 18 | export type StoriesParams = { p?: number, n?: number, next?: number, id?: string, site?: string }; 19 | export type StoriesData = { 20 | items: ForAwaitable, 21 | moreLink: Awaitable 22 | fromCache?: boolean, 23 | fromCacheDate?: Date, 24 | } 25 | export type ThreadsData = { 26 | items: ForAwaitable, 27 | moreLink: Awaitable 28 | fromCacheDate?: Date, 29 | } 30 | 31 | export enum Quality { 32 | AAA = 'c00', 33 | AA = 'c5a', 34 | A = 'c73', 35 | BBB = 'c82', 36 | BB = 'c88', 37 | B = 'c9c', 38 | C = 'cae', 39 | D = 'cbe', 40 | E = 'cce', 41 | F = 'cdd', 42 | default = '', 43 | }; 44 | 45 | export interface AThing { 46 | type: Type, 47 | id: number, 48 | by: string, 49 | time?: number | string | Date, 50 | kids?: ForAwaitable, 51 | parts?: ForAwaitable, 52 | dead?: boolean, 53 | deleted?: boolean, 54 | } 55 | 56 | export interface AUser { 57 | about?: string, 58 | created?: number | string | Date, 59 | id: string, 60 | karma: number, 61 | submitted: number[], 62 | 63 | // FIXME: Don't include app-level data in schema.. 64 | fromCacheDate?: Date, 65 | } 66 | 67 | export interface AComment extends AThing { 68 | type: 'comment', 69 | level?: number, 70 | descendants?: number | null, 71 | text: string, 72 | quality: Quality, 73 | parent: number, 74 | story?: number, 75 | storyTitle?: string, 76 | } 77 | 78 | export interface APollOpt extends AThing { 79 | type: 'pollopt', 80 | poll: number, 81 | score: number | null, 82 | text: string, 83 | } 84 | 85 | export type Type = 'job' | 'story' | 'comment' | 'poll' | 'pollopt'; 86 | export interface APost extends AThing { 87 | title: string, 88 | dead: boolean, 89 | url: string 90 | score: number | null, 91 | descendants: number | null, 92 | text: string | null 93 | quality: Quality, 94 | parent?: number, 95 | story?: number, 96 | storyTitle?: string, 97 | moreLink?: Awaitable, 98 | 99 | // FIXME: Don't include app-level data in schema.. 100 | fromCacheDate?: Date, 101 | } 102 | -------------------------------------------------------------------------------- /src/api/iter.ts: -------------------------------------------------------------------------------- 1 | export async function* aMap(as: AsyncIterable, f: (a: A) => B) { 2 | for await (const a of as) yield f(a) 3 | } 4 | 5 | export async function* aTake(n: number, xs: AsyncIterable): AsyncIterableIterator { 6 | let i = 0; 7 | for await (const x of xs) { 8 | if (++i > n) break; 9 | yield x; 10 | } 11 | } 12 | 13 | export async function* aConcat(as: ForAwaitable, bs: ForAwaitable): AsyncIterableIterator { 14 | for await (const a of as) yield a; 15 | for await (const b of bs) yield b; 16 | } 17 | 18 | export const isIterable = (x: unknown): x is Iterable => 19 | x != null && typeof x === 'object' && Symbol.iterator in x 20 | 21 | export const isAsyncIterable = (x: unknown): x is AsyncIterable => 22 | x != null && typeof x === 'object' && Symbol.asyncIterator in x 23 | 24 | export type ForAwaitable = Iterable | AsyncIterable; 25 | export type ForAwaitIterator = AsyncIterator | Iterator 26 | export type ForAwaitIterableIterator = AsyncIterableIterator | IterableIterator 27 | 28 | // const toIterableIterator = (iter: Iterator): IterableIterator => ({ 29 | // ...iter, 30 | // [Symbol.iterator]() { return this } 31 | // }) 32 | 33 | // const toAsyncIterableIterator = (iter: AsyncIterator): AsyncIterableIterator => ({ 34 | // ...iter, 35 | // [Symbol.asyncIterator]() { return this } 36 | // }) 37 | 38 | const iterableIterator = (iterable: Iterable): IterableIterator => { 39 | const iter = iterable[Symbol.iterator]() 40 | return { 41 | ...iter, 42 | [Symbol.iterator]() { return this } 43 | } 44 | } 45 | const aIterableIterator = (iterable: AsyncIterable): AsyncIterableIterator => { 46 | const iter = iterable[Symbol.asyncIterator]() 47 | return { 48 | ...iter, 49 | [Symbol.asyncIterator]() { return this } 50 | } 51 | } 52 | 53 | export const getForAwaitIterator = (iterable: ForAwaitable): ForAwaitIterableIterator => isAsyncIterable(iterable) 54 | ? aIterableIterator(iterable) 55 | : iterableIterator(iterable) 56 | -------------------------------------------------------------------------------- /src/api/make-api.ts: -------------------------------------------------------------------------------- 1 | import { APost, AComment, Stories, StoriesParams, AUser, ThreadsData, StoriesData, Quality } from './interface.ts'; 2 | import { default as PQueue } from '@qwtel/p-queue-browser'; 3 | import { ResolvablePromise } from '@worker-tools/resolvable-promise'; 4 | import { blockquotify } from './rewrite-content.ts'; 5 | import * as domAPI from './dom-api.ts'; 6 | import { Awaitable } from "../vendor/common-types.ts"; 7 | 8 | type APIFn = (path: string) => Promise; 9 | 10 | type RESTPost = Omit & { kids?: number[], time: number } 11 | type RESTComment = Omit & { kids?: number[], time: number, priority: number } 12 | type RESTUser = AUser; 13 | 14 | const CONCURRENCY = 32; 15 | 16 | const PAGE = 30; 17 | 18 | const storiesToPaths = { 19 | [Stories.TOP]: `/v0/topstories`, 20 | [Stories.NEW]: '/v0/newstories', 21 | [Stories.BEST]: '/v0/beststories', 22 | [Stories.SHOW]: '/v0/showstories', 23 | [Stories.SHOW_NEW]: '', 24 | [Stories.ASK]: '/v0/askstories', 25 | [Stories.JOB]: '/v0/jobstories', 26 | [Stories.USER]: '', 27 | [Stories.CLASSIC]: '', 28 | [Stories.FROM]: '', 29 | [Stories.OFFLINE]: '', 30 | } as const; 31 | 32 | export function stories(api: APIFn, params: StoriesParams, type = Stories.TOP): Awaitable { 33 | const { p } = params; 34 | const page = Math.max(1, p || 1); 35 | const href = storiesToPaths[type]; 36 | 37 | // If empty string, page doesn't have a REST/Firebase API, scaping HN web instead... 38 | if (href === '') 39 | return domAPI.stories(params, type) 40 | 41 | return { 42 | items: storiesGenerator(api, href, page), 43 | moreLink: `${type}?p=${page + 1}` 44 | } 45 | } 46 | 47 | export async function* storiesGenerator(api: APIFn, href: string, page: number) { 48 | const ps = (await api(href)) 49 | .slice(PAGE * (page - 1), PAGE * page) 50 | .map(id => api(`/v0/item/${id}`)); 51 | 52 | for await (const { kids, text, url, ...p } of ps) { 53 | yield { 54 | ...p, 55 | time: new Date(p.time * 1000), 56 | text: text != null ? await blockquotify(text) : null, 57 | url: text != null ? `item?id=${p.id}` : url, 58 | } as APost; 59 | } 60 | } 61 | 62 | async function commentTask( 63 | api: APIFn, 64 | id: number, 65 | queue: PQueue, 66 | results: Map>, 67 | topPriority: number, 68 | topFraction = 1, 69 | level = 1, 70 | ) { 71 | const data = await api(`/v0/item/${id}`); 72 | const kids = data.kids ?? []; 73 | const fraction = topFraction / kids.length; 74 | results.get(data.id)?.resolve({ ...data, priority: topPriority }); 75 | for (const [i, kidId] of kids.entries()) { 76 | results.set(kidId, new ResolvablePromise()); 77 | // HACK: Calculating a priority so that replies to top comments get moved to the front of the queue. 78 | // Further, top relies to top relies get higher priority than then the second reply to the top reply, etc. 79 | // If you're familiar with HN-style comment trees, this should make sense. Otherwise probably not. 80 | // It works by partitioning the real number line and increasingly "zooming in". 81 | // I have no proof that this does the correct thing (it probably doesn't), but it's close enough... 82 | // Significantly speeds up loading time of "above fold" content. 83 | const subPriority = (kids.length - i - 1) * fraction; 84 | const priority = topPriority + subPriority; 85 | queue.add(() => commentTask(api, kidId, queue, results, priority, fraction, level + 1), { priority }); 86 | } 87 | } 88 | 89 | async function* crawlCommentTree(kids: number[], dict: Map>, level = 0): AsyncGenerator { 90 | for (const kid of kids) { 91 | const item = await dict.get(kid); 92 | if (item) { 93 | const { kids, text, priority, ...rest } = item; 94 | yield { 95 | ...rest, 96 | level, 97 | quality: item.deleted ? Quality.default: item.dead ? Quality.F : Quality.AAA, // REST API doesn't support quality.. 98 | text: text && await blockquotify('

' + text), 99 | time: new Date(item.time * 1000), 100 | } 101 | yield* crawlCommentTree(kids || [], dict, level + 1) 102 | } 103 | } 104 | } 105 | 106 | /** Takes an iterable and returns an iterator that does not stop the iteration at the end of a for-of loop. */ 107 | function unclosed(iterable: AsyncIterable): AsyncIterableIterator { 108 | const iterator = iterable[Symbol.asyncIterator](); 109 | return { 110 | next: iterator.next.bind(iterator), 111 | [Symbol.asyncIterator]() { return this } 112 | }; 113 | } 114 | 115 | const C_PAGE = 250 116 | 117 | class Paginator { 118 | #iterable; 119 | #total; 120 | #page; 121 | #more = new ResolvablePromise>(); 122 | 123 | constructor(iterable: AsyncIterableIterator, total: number, page = 1) { 124 | this.#iterable = iterable; 125 | this.#total = total; 126 | this.#page = page; 127 | } 128 | 129 | async *[Symbol.asyncIterator]() { 130 | const iterable = this.#iterable; 131 | const total = this.#total; 132 | const page = this.#page 133 | 134 | let n = 0; 135 | let comm; 136 | for (let p = 1; p < page; p++) { 137 | let i = 0; 138 | for await (comm of unclosed(iterable)) if (!comm.dead) { 139 | if (i >= C_PAGE && comm.level === 0) break; 140 | i++ 141 | } 142 | n += i; 143 | } 144 | if (n > total) return; 145 | if (comm) yield comm; 146 | let i = 0; 147 | for await (const comm of unclosed(iterable)) if (!comm.dead) { 148 | if (i >= C_PAGE && comm.level === 0) break; 149 | yield comm; 150 | i++ 151 | } 152 | 153 | for await (const comm of iterable) if (!comm.dead) { 154 | this.#more.resolve({ done: false, value: comm }) 155 | break; 156 | } 157 | this.#more.resolve({ done: true, value: undefined }) 158 | } 159 | get more() { 160 | return Promise.resolve(this.#more) 161 | } 162 | } 163 | 164 | // FIXME: Match HN behavior more closely 165 | const truncateText = (text?: string | null) => { 166 | if (text) { 167 | const words = text.split(' '); 168 | const trunc = words.splice(0, 11).join(' '); 169 | return words.length > 11 ? trunc + ' ...' : trunc; 170 | } 171 | return ''; 172 | } 173 | 174 | const stripHTML = (text?: string | null) => text ? text.replace(/(<([^>]+)>)/gi, "") : ''; 175 | 176 | export async function comments(api: APIFn, id: number, p = 1): Promise { 177 | const post: RESTPost = await api(`/v0/item/${id}`); 178 | // console.log(post) 179 | 180 | if (post.type === 'comment') { 181 | let curr = post; 182 | while (curr.parent) { 183 | curr = await api(`/v0/item/${curr.parent}`); 184 | } 185 | post.story = curr.id 186 | post.storyTitle = truncateText(curr.title) 187 | } 188 | 189 | const queue = new PQueue({ concurrency: CONCURRENCY }); 190 | const kids = post.kids ?? []; 191 | const results = new Map(kids.map(id => [id, new ResolvablePromise()])); 192 | for (const [i, kid] of kids.entries()) { 193 | const priority = kids.length - i; 194 | queue.add(() => commentTask(api, kid, queue, results, priority), { priority: kids.length - i }); 195 | } 196 | 197 | const text = post.text != null ? await blockquotify('

' + post.text) : null; 198 | const commCrawler = new Paginator(crawlCommentTree(kids, results), post.descendants ?? Number.POSITIVE_INFINITY, p); 199 | 200 | const retPost = { 201 | type: post.type ?? '', 202 | title: post.title || truncateText(stripHTML(text)), 203 | parent: post.parent ?? NaN, 204 | story: post.story ?? NaN, 205 | storyTitle: post.storyTitle ?? '', 206 | score: post.score ?? NaN, 207 | by: post.by ?? '', 208 | descendants: post.descendants ?? NaN, 209 | text, 210 | quality: post.deleted ? Quality.default : post.dead ? Quality.F : Quality.AAA, 211 | deleted: post.deleted ?? false, 212 | dead: post.dead ?? false, 213 | id: post.id ?? null, 214 | url: post.text != null ? `item?id=${post.id}` : post.url, // FIXME 215 | time: new Date(post.time * 1000) ?? null, 216 | parts: post.parts ?? [], 217 | kids: commCrawler, 218 | moreLink: commCrawler.more.then(({ done, value }) => done ? '' : `item?id=${post.id}&p=${p + 1}&next=${value.id}`), 219 | } 220 | return retPost 221 | } 222 | 223 | export async function user(api: APIFn, id: string): Promise { 224 | const { about, ...user }: RESTUser = await api(`/v0/user/${id}`); 225 | return { 226 | ...user, 227 | ...about ? { about: await blockquotify('

' + about) } : {}, 228 | }; 229 | } 230 | 231 | export function threads(_api: APIFn, id: string, next?: number): Promise { 232 | // Has no REST/Firebase equivalent, scraping web page instead... 233 | return domAPI.threads(id, next); 234 | } 235 | -------------------------------------------------------------------------------- /src/api/rest-api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An implementation of Hacker News API using the Firebase REST API. 3 | * 4 | * The REST API has many drawbacks, the #1 of which is the amount of HTTP requests that are necessary to get 5 | * e.g. the comment tree. 6 | * 7 | * Because of this, it is not suitable for Cloudflare Workers, which limits the # HTTP requests per invocation to ~50. 8 | * (Workers Unbound might change that, but as of this writing, it is not available to the public). 9 | * 10 | * To make the best of this limitation, the functions below make use of Task Queues and Async Iterables to push 11 | * results to the client ASAP. While slower than all the alternatives, it's still cool to see in action 12 | * (comments streaming in, etc...). 13 | * 14 | * It works best in Deno where there's no limit to the # of open HTTP connections. 15 | * It also works in a Service Worker, but due to the limit of 4 (?) open connections per page, it's noticeably slower. 16 | */ 17 | 18 | import { Stories, StoriesParams } from './interface.ts'; 19 | import * as mkAPI from './make-api.ts'; 20 | 21 | export const API = 'https://hacker-news.firebaseio.com'; 22 | 23 | export const api = (path: string): Promise => { 24 | const url = new URL(path.endsWith('.json') ? path : `${path}.json`, API); 25 | return fetch(url.href).then(x => x.json()); 26 | } 27 | 28 | export function stories(params: StoriesParams, type = Stories.TOP) { 29 | return mkAPI.stories(api, params, type); 30 | } 31 | 32 | export function comments(id: number, p?: number) { 33 | return mkAPI.comments(api, id, p); 34 | } 35 | 36 | export function user(id: string) { 37 | return mkAPI.user(api, id); 38 | } 39 | 40 | export function threads(id: string, next?: number) { 41 | return mkAPI.threads(api, id, next); 42 | } -------------------------------------------------------------------------------- /src/api/rewrite-content.ts: -------------------------------------------------------------------------------- 1 | import { DOMParser } from 'linkedom' 2 | import { location } from '../location.ts' 3 | 4 | const TEXT_NODE = 3; 5 | const SHOW_TEXT = 4; 6 | 7 | const HN_LINK = /https?:\/\/news.ycombinator.com/; 8 | 9 | type TreeWalker = any; 10 | type Node = any; 11 | 12 | export function* treeWalkerToIter(walker: TreeWalker): IterableIterator { 13 | let node; while (node = walker.nextNode()) yield node; 14 | } 15 | 16 | // Primitive support for 17 | // Problem: item?id=26520957, item?id=30283264 18 | export function blockquotify(text: string) { 19 | const doc = new DOMParser().parseFromString(text, 'text/html') 20 | let match; 21 | for (const p of doc.querySelectorAll('p')) { 22 | for (const a of p.querySelectorAll('a[href^="http://news.ycombinator.com"], a[href^="https://news.ycombinator.com"]')) { 23 | const href = a.getAttribute('href')!.replace(HN_LINK, `${location.protocol}//${location.host}`); 24 | a.setAttribute('href', href) 25 | } 26 | 27 | // Test nested: http://localhost:8787/item?id=30297007 28 | // Wrong: http://localhost:8787/item?id=30405883 29 | if (match = /^([|>])/.exec(p.textContent.trim())) { 30 | const bq = doc.createElement('blockquote'); 31 | bq.innerHTML = p.innerHTML; 32 | for (const nd of treeWalkerToIter(doc.createTreeWalker(bq, SHOW_TEXT))) { 33 | if (/^([|>])/.test(nd.textContent.trim())) { 34 | nd.textContent = nd.textContent.trim().substring(1); 35 | break; 36 | } 37 | } 38 | const span = doc.createElement('span'); span.textContent = match[1]; span.classList.add('sr-only'); 39 | bq.prepend(span); 40 | p.outerHTML = bq.outerHTML; 41 | } 42 | 43 | // Test: item?id=26514612, item?id=26545082, item?id=30282629 44 | if (match = /^([-*])[^-*]/.exec(p.textContent.trim())) { 45 | const li = doc.createElement('li') 46 | li.innerHTML = p.innerHTML; 47 | for (const x of treeWalkerToIter(doc.createTreeWalker(li, SHOW_TEXT))) { 48 | if (/^([-*])[^-*]/.test(x.textContent.trim())) { 49 | x.textContent = x.textContent.trim().substring(1); 50 | break; 51 | } 52 | } 53 | const span = doc.createElement('span'); span.textContent = match[1]; span.classList.add('sr-only'); 54 | li.prepend(span); 55 | p.outerHTML = li.outerHTML; 56 | } 57 | 58 | // Test: item?id=30244534 59 | if (match = /^([-*=]{3,})$/.exec(p.textContent.trim())) { 60 | p.outerHTML = `


${match[1]}`; 61 | } 62 | } 63 | 64 | return doc.toString(); 65 | 66 | // const resp1 = new Response(text); 67 | // // const resp2 = resp1.clone(); 68 | 69 | // const rewriter = h2r(new HTMLRewriter()) 70 | // .on('a[href^="http://news.ycombinator.com"], a[href^="https://news.ycombinator.com"]', { 71 | // element(a) { 72 | // const href = a.getAttribute('href')! 73 | // .replace(/https?://news.ycombinator.com/g, `${protocol}//${host}`); 74 | // a.setAttribute('href', href) 75 | // }, 76 | // }) 77 | // .on('p', { 78 | // element(el) { 79 | // }, 80 | // text(chunk) { 81 | // const text = chunk.text; 82 | // let match; 83 | // if (text.startsWith('>')) { 84 | // const bq = `
>${text.substring(4)}
` 85 | // chunk.replace(bq, { html: true }) 86 | // } 87 | // else if (match = /^([-*])[^-*]/.exec(text)) { 88 | // const li = `
  • ${match[1]}${text.substring(1)}
  • ` 89 | // chunk.replace(li, { html: true }) 90 | // } 91 | // else if (match = /^([-*]{3,})$/.exec(text)) { 92 | // const hr = `
    ${match[1]}` 93 | // chunk.replace(hr, { html: true }) 94 | // } 95 | // } 96 | // }) 97 | 98 | // return await r2h(rewriter) 99 | // .transform(resp1) 100 | // .text() 101 | } 102 | 103 | /** 104 | * Consumes a `Response` body while discarding all chunks. 105 | * Useful for pulling data into `HTMLRewriter`. 106 | */ 107 | export async function consume(r: ReadableStream, signal?: AbortSignal) { 108 | const reader = r.getReader(); 109 | if (!signal) { 110 | while (!(await reader.read()).done) { /* NOOP */ } 111 | } else { 112 | const aborted = signal.aborted 113 | ? Promise.resolve() 114 | : new Promise(res => signal.addEventListener('abort', res, { once: true })); 115 | while (await Promise.race([ 116 | reader.read().then(x => !x.done), 117 | aborted.then(() => false), 118 | ])) { /* NOOP */ } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/api/sw-api.ts: -------------------------------------------------------------------------------- 1 | import { JSONRequest, ParamsURL } from "@worker-tools/json-fetch"; 2 | import { ResolvablePromise } from "@worker-tools/resolvable-promise"; 3 | import { JSONParseNexus } from '@worker-tools/json-stream'; 4 | // import { notImplemented } from "@worker-tools/response-creators"; 5 | import { liftAsync, PromisedEx } from "../vendor/awaited-values.ts"; 6 | import { APost, AUser, Stories, StoriesData, StoriesParams, ThreadsData } from "./interface.ts"; 7 | 8 | type MinArgs = { url: URL, handled: Promise, waitUntil: (f?: any) => void }; 9 | 10 | const MIN_WAIT = 350; 11 | const NEVER = new Promise(() => {}); 12 | const timeout = (n?: number) => new Promise(r => setTimeout(r, n)) 13 | 14 | const networkFirst = (cacheKey: string) => async ({ url, handled, waitUntil }: MinArgs): Promise => { 15 | const forceFetch = url.searchParams.get('force') === 'fetch' 16 | const forceCache = url.searchParams.get('force') === 'cache' 17 | url.searchParams.delete('force') 18 | 19 | // FIXME: What a mess. But need to call waitUntil immediately, otherwise event gets garbage collected... 20 | const done = new ResolvablePromise() 21 | waitUntil(done) 22 | 23 | try { 24 | const req = new JSONRequest(url); 25 | const race = { over: false, rejected: false } 26 | const useFetch = forceFetch || (navigator.onLine && !forceCache) 27 | const res = await Promise.race([ 28 | useFetch 29 | ? fetch(req).catch(err => (race.rejected = true, forceFetch ? Promise.reject(err) : NEVER)) 30 | : NEVER, 31 | forceFetch 32 | ? NEVER 33 | : timeout(useFetch ? MIN_WAIT : 0) 34 | .then(() => !race.over ? self.caches.match(req) : undefined) 35 | .then(res => res === undefined && !race.over && !race.rejected && !forceCache ? NEVER : res) 36 | ]) 37 | race.over = true; 38 | if (!res) throw Error('You are offline'); 39 | 40 | const fromCache = res.headers.has('x-from-sw-cache'); 41 | if (fromCache) { 42 | done.resolve() 43 | } else { 44 | (async () => { 45 | await handled 46 | const mutRes = new Response(res.body, res); 47 | mutRes.headers.set('x-from-sw-cache', 'true') 48 | const cache = await self.caches.open(cacheKey) 49 | await cache.put(req, mutRes) 50 | })().finally(() => done.resolve()) 51 | } 52 | 53 | // return data as T; 54 | return res.clone() 55 | } catch (err) { 56 | done.resolve(); 57 | throw err 58 | } 59 | } 60 | 61 | export async function stories(params: StoriesParams, type = Stories.TOP, args: MinArgs): Promise { 62 | const res = await networkFirst('stories')(args); 63 | const jsonStream = new JSONParseNexus() 64 | const ret = { 65 | items: jsonStream.iterable('$.items.*'), 66 | moreLink: jsonStream.promise('$.moreLink'), 67 | fromCacheDate: res.headers.has('x-from-sw-cache') ? new Date(res.headers.get('date')!) : undefined 68 | } 69 | res.body!.pipeThrough(jsonStream) 70 | return ret; 71 | } 72 | 73 | export async function comments(id: number, p: number | undefined, args: MinArgs): Promise { 74 | const res = await networkFirst('comments')(args); 75 | const nxs = new JSONParseNexus() 76 | const data: PromisedEx, 'moreLink' | 'fromCacheDate' | 'kids' | 'parts'> = { 77 | type: nxs.promise('$.type'), 78 | title: nxs.promise('$.title'), 79 | parent: nxs.promise('$.parent'), 80 | storyTitle: nxs.promise('$.storyTitle'), 81 | story: nxs.promise('$.story'), 82 | score: nxs.promise('$.score'), 83 | by: nxs.promise('$.by'), 84 | descendants: nxs.promise('$.descendants'), 85 | text: nxs.promise('$.text'), 86 | quality: nxs.promise('$.quality'), 87 | dead: nxs.promise('$.dead'), 88 | id: nxs.promise('$.id'), 89 | url: nxs.promise('$.url'), 90 | time: nxs.promise('$.time'), 91 | parts: nxs.iterable('$.parts.*'), 92 | kids: nxs.iterable('$.kids.*'), 93 | moreLink: nxs.promise('$.moreLink').map(x => x!), 94 | fromCacheDate: res.headers.has('x-from-sw-cache') ? new Date(res.headers.get('date')!) : undefined 95 | } 96 | res.body!.pipeThrough(nxs) 97 | const lifted = await liftAsync(data, { exclude: ['moreLink', 'fromCacheDate', 'kids', 'parts'] }); 98 | return lifted as APost 99 | } 100 | 101 | export async function user(id: string, args: MinArgs): Promise { 102 | return (await networkFirst('user')(args)).json(); 103 | } 104 | 105 | export async function threads(id: string, next: number | undefined, args: MinArgs): Promise { 106 | return (await networkFirst('threads')(args)).json(); 107 | } 108 | -------------------------------------------------------------------------------- /src/entry/cf.ts: -------------------------------------------------------------------------------- 1 | import '@worker-tools/location-polyfill'; 2 | import '../vendor/custom-event-polyfill.ts'; 3 | import { router } from '../routes/index.ts'; 4 | self.addEventListener('fetch', router); 5 | -------------------------------------------------------------------------------- /src/entry/deno-globals.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(self, 'DEBUG', { value: true, writable: false }) 2 | Object.defineProperty(self, 'SW', { value: false, writable: false }) 3 | Object.defineProperty(self, 'WORKER_LOCATION', { value: 'https://news.workers.tools', writable: false }) -------------------------------------------------------------------------------- /src/entry/deno.ts: -------------------------------------------------------------------------------- 1 | import './globals.ts' 2 | import './deno-globals.ts' 3 | import '@worker-tools/html-rewriter/polyfill' 4 | import { router } from '../routes/index.ts'; 5 | import { serve } from "https://deno.land/std@0.158.0/http/server.ts"; 6 | await serve(router.serveCallback); -------------------------------------------------------------------------------- /src/entry/globals.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | const DEBUG: boolean; 3 | const SW: boolean; 4 | 5 | interface Window { 6 | readonly DEBUG: boolean; 7 | readonly SW: boolean; 8 | readonly WORKER_LOCATION: string; 9 | } 10 | 11 | interface Navigator { 12 | readonly onLine?: boolean; 13 | } 14 | 15 | interface CacheStorage { 16 | readonly default: Cache; 17 | match( 18 | request: RequestInfo | URL, 19 | options?: CacheQueryOptions, 20 | ): Promise; 21 | } 22 | 23 | interface CacheQueryOptions { 24 | cacheControl: string 25 | } 26 | 27 | interface Cache { 28 | keys(): Promise 29 | addAll(requests: (string|Request)[]): Promise 30 | } 31 | } 32 | 33 | export {} 34 | -------------------------------------------------------------------------------- /src/entry/sw-def.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(self, 'DEBUG', { value: true, writable: false }) 2 | Object.defineProperty(self, 'SW', { value: true, writable: false }) -------------------------------------------------------------------------------- /src/entry/sw.ts: -------------------------------------------------------------------------------- 1 | import './globals.ts' 2 | import 'urlpattern-polyfill' 3 | 4 | import './sw-def.ts' 5 | 6 | import { router } from '../routes/index.ts'; 7 | 8 | self.addEventListener('fetch', router); 9 | 10 | self.addEventListener('install', async (event: any) => { 11 | console.log('install') 12 | const cache = await self.caches.open('public') 13 | await cache.addAll([ 14 | '/darkbluearrow.png', 15 | '/darkbluearrow2x.png', 16 | '/darky18.png', 17 | '/favicon.ico', 18 | '/grayarrow.gif', 19 | '/grayarrow2x.gif', 20 | '/hn.js?v=26', 21 | '/new.png', 22 | '/news.css?v=26', 23 | '/s.gif', 24 | '/y18.png', 25 | '/y18.gif', 26 | '/app.webmanifest', 27 | ...await fetch('/app.webmanifest').then(x => x.json()).then((m: any) => m.icons.map((i: any) => i.src)) 28 | ]) 29 | console.log('installed') 30 | // TODO: debug only? 31 | // console.log('skipWaiting'); 32 | // (self).skipWaiting(); 33 | }); 34 | 35 | self.addEventListener('activate', (event: any) => { 36 | console.log('activated') 37 | // TODO: debug only? 38 | // event.waitUntil((self).clients.claim()); 39 | // console.log('claim'); 40 | }); -------------------------------------------------------------------------------- /src/location.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore: worker location not defined in deno 2 | class WorkerLocationPolyfill2 implements WorkerLocation { 3 | #url: URL; 4 | constructor(href: string) { this.#url = new URL(href) } 5 | get hash(): string { return '' } 6 | get host(): string { return this.#url.host } 7 | get hostname(): string { return this.#url.hostname } 8 | get href(): string { return this.#url.href } 9 | get origin(): string { return this.#url.origin } 10 | get pathname(): string { return '/' } 11 | get port(): string { return this.#url.port } 12 | get protocol(): string { return this.#url.protocol } 13 | get search(): string { return '' } 14 | toString(): string { return this.href } 15 | } 16 | 17 | const envLoc = ((self).WORKER_LOCATION) ?? ((self).process?.env?.WORKER_LOCATION) 18 | export const location = self.location || new WorkerLocationPolyfill2(envLoc) 19 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { basics, combine, contentTypes, Context } from '@worker-tools/middleware'; 2 | import { WorkerRouter, Method } from '@worker-tools/router'; 3 | 4 | export const mw = combine(basics(), contentTypes(['text/html', 'application/json', '*/*'])) 5 | 6 | export type RouteArgs = Awaited> 7 | 8 | export const router = new WorkerRouter(x => x, { fatal: false }) 9 | router.addEventListener('error', ({ error, message }) => console.warn('err', message)) 10 | 11 | -------------------------------------------------------------------------------- /src/routes/assets.ts: -------------------------------------------------------------------------------- 1 | import { getAssetFromKV, mapRequestToAsset, Options } from '@cloudflare/kv-asset-handler' 2 | import { internalServerError, notFound } from '@worker-tools/response-creators' 3 | 4 | export async function handler(req: Request, event: { request: Request, waitUntil: (_f: any) => void }) { 5 | const options: Partial = {} 6 | 7 | /** 8 | * You can add custom logic to how we fetch your assets 9 | * by configuring the function `mapRequestToAsset` 10 | */ 11 | // options.mapRequestToAsset = handlePrefix(/^\/docs/) 12 | 13 | try { 14 | if (DEBUG) { 15 | // customize caching 16 | options.cacheControl = { 17 | bypassCache: false, 18 | } 19 | } 20 | 21 | let page: Response; 22 | if ('__STATIC_CONTENT' in self) { 23 | page = await getAssetFromKV(event, options) 24 | } else if ('Deno' in globalThis) { 25 | const url = new URL(event.request.url); 26 | const assetHref = new URL(`../../public${url.pathname}`, import.meta.url).href; 27 | // console.log(assetHref) 28 | page = await fetch(assetHref) 29 | } else { // Service Worker 30 | page = (await self.caches.match(event.request)) ?? await fetch(event.request) 31 | } 32 | 33 | // allow headers to be altered 34 | const response = new Response(page.body, page) 35 | 36 | if (req.url.endsWith('.js')) response.headers.set('content-type', 'text/javascript') 37 | if (req.url.endsWith('.wasm')) response.headers.set('content-type', 'application/wasm') 38 | if (req.url.endsWith('.css')) response.headers.set('content-type', 'text/css') 39 | if (req.url.endsWith('.html')) response.headers.set('content-type', 'text/html') 40 | if (req.url.endsWith('.webmanifest')) response.headers.set('content-type', 'application/manifest+json') 41 | 42 | // response.headers.set('X-XSS-Protection', '1; mode=block') 43 | // response.headers.set('X-Content-Type-Options', 'nosniff') 44 | // response.headers.set('X-Frame-Options', 'DENY') 45 | // response.headers.set('Referrer-Policy', 'unsafe-url') 46 | // response.headers.set('Feature-Policy', 'none') 47 | 48 | return response 49 | 50 | } catch (e) { 51 | console.error(e) 52 | // if an error is thrown try to serve the asset at 404.html 53 | // if (!DEBUG) { 54 | // console.warn(e) 55 | // try { 56 | // let notFoundResponse = await getAssetFromKV(event, { 57 | // mapRequestToAsset: req => new Request(`${new URL(req.url).origin}/404.html`, req), 58 | // }) 59 | 60 | // return notFound(notFoundResponse.body!, notFoundResponse) 61 | // } catch (e) {} 62 | // } 63 | 64 | return internalServerError(e instanceof Error ? e.message : e as string); 65 | } 66 | } 67 | 68 | /** 69 | * Here's one example of how to modify a request to 70 | * remove a specific prefix, in this case `/docs` from 71 | * the url. This can be useful if you are deploying to a 72 | * route on a zone, or if you only want your static content 73 | * to exist at a specific path. 74 | */ 75 | function handlePrefix(prefix: string | RegExp) { 76 | return (request: Request) => { 77 | // compute the default (e.g. / -> index.html) 78 | const defaultAssetKey = mapRequestToAsset(request) 79 | const url = new URL(defaultAssetKey.url) 80 | 81 | // strip the prefix from the path for lookup 82 | url.pathname = url.pathname.replace(prefix, '/') 83 | 84 | // inherit all other props from the default request 85 | return new Request(url.toString(), defaultAssetKey) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/routes/components.ts: -------------------------------------------------------------------------------- 1 | import { html, HTMLContent } from "@worker-tools/html"; 2 | import { formatDistanceToNowStrict } from 'date-fns'; 3 | 4 | import { Stories } from "../api/interface.ts"; 5 | import { location } from '../location.ts'; 6 | 7 | export const isSafari = (ua?: string | null) => !!ua && /Safari\/\d+/.test(ua) && !/(Chrome|Chromium)\/\d+/.test(ua) 8 | 9 | const topSel = (wrap: boolean, content: HTMLContent) => wrap 10 | ? html`${content}` 11 | : content 12 | 13 | export const favicon = (url?: { hostname?: string } | null) => { 14 | const img = url?.hostname && url.hostname !== location.hostname ? `favicon/${url.hostname}.ico` : `darky18.png` 15 | return html`` 16 | } 17 | 18 | export const identicon = (by: string, size = 11) => { 19 | const img = by === 'dang' ? 'y18.gif' : `identicon/${by}.svg`; 20 | return html`` 21 | } 22 | 23 | export const cachedWarning = ({ fromCacheDate }: { fromCacheDate?: Date | null }, request: Request) => { 24 | if (fromCacheDate) { 25 | const forceUrl = new URL(request.url) 26 | forceUrl.searchParams.set('force', 'fetch') 27 | const timeAgo = formatDistanceToNowStrict(fromCacheDate, { addSuffix: true }) 28 | return html`Reading offline page. Last updated ${ 29 | timeAgo}.
    Force Refresh.` 30 | } 31 | } 32 | 33 | export const del = (content: HTMLContent) => navigator.onLine ? content : html`${content}` 34 | 35 | export const headerEl = ({ op, id, p = 1 }: { 36 | op: Stories | 'item' | 'user' | 'threads', 37 | id?: string, 38 | p?: number, 39 | }) => html` 40 | 41 | 42 | 43 | 44 | 45 | 49 | 77 | 90 | 91 | 92 |
    Worker News 51 | ${topSel(op === Stories.NEW, html`new`)} 52 | 53 | 54 | 55 | | ${topSel(op === Stories.ASK, html`ask`)} 56 | | ${topSel(op === Stories.SHOW, html`show`)} 57 | | ${topSel(op === Stories.JOB, html`jobs`)} 58 | | ${topSel(op === Stories.BEST, html`best`)} 59 | ${SW && topSel(op === Stories.OFFLINE, html`| offline`)} 60 | | submit 61 | ${op === Stories.SHOW_NEW 62 | ? html`| ${op}` 63 | : ''} 64 | ${op === Stories.USER 65 | ? html`| ${id}'s submissions` 66 | : ''} 67 | ${op === 'threads' 68 | ? html`| ${id}'s comments` 69 | : ''} 70 | ${op === 'from' 71 | ? html`| from` 72 | : ''} 73 | ${op === 'item' && p > 1 74 | ? html`| page ${p}` 75 | : ''} 76 | 78 | 79 | 80 |
    81 | 83 |
    84 | ${/*session?.user 85 | ? html`${session.user} 86 | ${apiUser(session.user).then(x => `(${x.karma})`)} 87 | | logout` 88 | : html`login`*/null} 89 |
    93 | 94 | 95 | `; 96 | 97 | export const footerEl = () => html` 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |

    107 |
    Made by ${identicon('qwtel', 13)} qwtel with ⚙️ Worker Tools and 🦕 Deno Deploy.

    110 |
    YC: 111 | Guidelines 112 | | FAQ 113 | 114 | | API 115 | | Security 116 | | Legal 117 | | Apply to YC 118 | | Contact YC

    119 |
    Search: 120 | 122 |
    123 |
    124 | 125 | `; 126 | 127 | const tcLight = '#fff'; 128 | const tcDark = '#101114'; // --blue 129 | const saLight = '#f6f6ef' // --beige 130 | const saDark = '#1c1d22'; // --darkest-blue 131 | const appLight = '#ee9b33'; 132 | const appDark = '#373a43'; 133 | 134 | export const pageLayout = ({ title, op, id, p, headers }: { 135 | title?: string, 136 | op: Stories | 'item' | 'user' | 'threads', 137 | id?: string, 138 | p?: number, 139 | headers?: Headers, 140 | }) => (content: HTMLContent) => html` 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 159 | 160 | 161 | 162 | 163 | ${title ? `${title} | Worker News` : 'Worker News'} 164 | 175 | 176 | 177 |
    178 | 179 | 180 | ${headerEl({ op, id, p })} 181 | ${content} 182 | ${footerEl()} 183 | 184 |
    185 |
    186 | 187 | ${location.hostname === 'news.workers.tools' 188 | ? html`` 189 | : ''} 190 | 191 | `; -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { basics, caching, combine, contentTypes } from "@worker-tools/middleware"; 2 | import { permanentRedirect, seeOther } from '@worker-tools/response-creators' 3 | import { manifestHandler } from './manifest-handler.js'; 4 | 5 | import { router, mw } from "../router.ts"; 6 | 7 | import * as assets from './assets.ts'; 8 | import { news } from './news.ts'; 9 | import './item.ts'; 10 | import './user.ts'; 11 | import './threads.ts'; 12 | // import './login'; 13 | 14 | router.get('/', mw, (req, x) => news(x)) 15 | 16 | if (!SW) router.get('/app.webmanifest', manifestHandler) 17 | 18 | router.get('/paste', mw, (req, x) => { 19 | const u = new URL(x.searchParams.get('q') || "", location.origin); 20 | u.protocol = location.protocol; 21 | u.host = location.host; 22 | return permanentRedirect(u.href); 23 | }); 24 | 25 | router.get('*', caching({ 26 | public: true, 27 | maxAge: 60 * 60 * 24 * 30 * 12 28 | }), assets.handler) 29 | 30 | export { router } -------------------------------------------------------------------------------- /src/routes/item.ts: -------------------------------------------------------------------------------- 1 | import { html, unsafeHTML, HTMLResponse, HTMLContent, BufferedHTMLResponse } from "@worker-tools/html"; 2 | import { basics, caching, combine, contentTypes } from "@worker-tools/middleware"; 3 | import { notFound, ok } from "@worker-tools/response-creators"; 4 | import { JSONStreamResponse, jsonStringifyGenerator } from '@worker-tools/json-stream'; 5 | import { renderIconSVG } from "@qwtel/blockies"; 6 | import { formatDistanceToNowStrict } from 'date-fns'; 7 | import { asyncIterableToStream, ForAwaitable } from "whatwg-stream-to-async-iter"; // FIXME 8 | import { StreamResponse } from "@worker-tools/stream-response"; 9 | 10 | import { mw, RouteArgs, router } from "../router.ts"; 11 | 12 | import { api, AComment, APost, Stories, APollOpt } from "../api/index.ts"; 13 | 14 | import { pageLayout, identicon, cachedWarning, isSafari } from './components.ts'; 15 | import { aThing, fastTTFB, subtext } from './news.ts'; 16 | import { moreLinkEl } from "./threads.ts"; 17 | 18 | export interface CommOpts { 19 | showToggle?: boolean, 20 | showReply?: boolean, 21 | showParent?: boolean, 22 | } 23 | 24 | export const commentTr = (comm: AComment, { showToggle = true, showReply = true, showParent = false }: CommOpts = {}) => { 25 | const { id, level, by, text, time, quality, deleted, parent, story, storyTitle } = comm; 26 | const timeAgo = time && formatDistanceToNowStrict(new Date(time), { addSuffix: true }) 27 | return html` 28 | 29 | 30 |
    ${deleted 31 | ? html`` 32 | : html`
    ` 33 | }
    34 | 35 | 36 |
    37 | 38 | ${!deleted && identicon(by)} ${by} 39 | ${timeAgo} 40 | 41 | 42 | ${showParent && parent ? html` | parent` : ''} 43 | 44 | ${showToggle 45 | ? html`[–]` 46 | : ''} 47 | ${showParent && story && storyTitle ? html` | on: ${storyTitle}`: ''} 48 | 49 |

    50 |
    51 | 52 | ${deleted ? '[deleted]' : text ? unsafeHTML(text) : ' '} 53 |
    54 |

    55 | ${showReply && !deleted ? html` 56 | ${/*reply*/''} 57 | ${/*reply*/''} 58 | reply 59 | ` : ''} 60 |

    61 |
    62 |
    63 |
    64 | 65 | `; 66 | } 67 | 68 | export const commentEl = (comment: AComment, commOpts: CommOpts = {}) => { 69 | // if (comment.dead) return ''; 70 | return html` 71 | 72 | 73 | 74 | ${commentTr(comment, commOpts)} 75 | 76 |
    77 | 78 | `; 79 | } 80 | 81 | const timeout = (n?: number) => new Promise(res => setTimeout(res, n)) 82 | 83 | export async function* commentTree(kids: ForAwaitable, parent: { dead: boolean }): AsyncGenerator { 84 | for await (const item of kids) { 85 | yield commentEl(item, { showReply: !parent.dead }); 86 | if (item.kids) yield* commentTree(item.kids, parent); 87 | } 88 | } 89 | 90 | export const pollOptEl = (opt: APollOpt) => { 91 | return html` 92 |
    93 |
    94 | ${opt.text} 95 |
    96 | ${opt.score} points 97 | `; 98 | } 99 | 100 | async function* pollOptList(parts: ForAwaitable): AsyncIterable { 101 | yield html` 102 | 103 | 104 | ${(async function*() { 105 | for await (const item of parts) { 106 | yield pollOptEl(item); 107 | } 108 | })()}
    109 | `; 110 | } 111 | 112 | const PLACEHOLDER = 'Loading…'; 113 | 114 | const replyTr = ({ id, type }: APost) => { 115 | return html` 116 | 117 | 118 | 119 |
    123 |

    124 | 125 | ${type === 'comment' ? 'reply' : 'add comment'} on HN 126 | 127 |
    128 | 129 | `; 130 | } 131 | 132 | export class ExponentialJoinStream extends TransformStream { 133 | constructor(joiner = ',') { 134 | let n = 0; 135 | let i = 0; 136 | let buffer: string[] = []; 137 | super({ 138 | start() { 139 | n = 4; 140 | i = 0; 141 | buffer = new Array(2**n); 142 | }, 143 | transform(chunk, controller) { 144 | buffer[i++] = chunk; 145 | if (i === 2**n) { 146 | i = 0; 147 | controller.enqueue(buffer.join(joiner)) 148 | buffer = new Array(2**++n); 149 | } 150 | }, 151 | flush(controller) { 152 | controller.enqueue(buffer.join(joiner)) 153 | buffer = []; 154 | }, 155 | }) 156 | } 157 | } 158 | 159 | 160 | // Dead items: 26841031 161 | function getItem({ request, headers, searchParams, type: contentType, url, handled, waitUntil }: RouteArgs) { 162 | const id = Number(searchParams.get('id')); 163 | if (Number.isNaN(id)) return notFound('No such item.'); 164 | const p = Math.max(1, Number(searchParams.get('p') || '1')); 165 | 166 | const postPromise = api.comments(id, p, { url, handled, waitUntil }); 167 | const pageRenderer = pageLayout({ title: PLACEHOLDER, op: 'item', p, headers }) 168 | 169 | if (contentType === 'application/json') { 170 | return new StreamResponse(fastTTFB(jsonStringifyGenerator(postPromise)), { 171 | headers: [['content-type', JSONStreamResponse.contentType]] 172 | }) 173 | } 174 | 175 | // const Ctor = isSafari(navigator.userAgent) ? BufferedHTMLResponse : HTMLResponse 176 | const content = pageRenderer(async () => { 177 | try { 178 | const post = await postPromise; 179 | const { title, text, kids, parts } = post; 180 | return html` 181 | 182 | ${title 183 | ? html`` 184 | : ''} 185 | 186 | 187 | 188 | 189 | ${cachedWarning(post, request)} 190 | ${post.type === 'comment' 191 | ? [commentTr(post as AComment, { showParent: true, showToggle: false })] 192 | : [ 193 | aThing(post), 194 | subtext(post, undefined, undefined, { showPast: true }), 195 | text 196 | ? html`` 197 | : '', 198 | parts 199 | ? pollOptList(parts) 200 | : '', 201 | ] 202 | }${!post.dead && !searchParams.has('p') ? replyTr(post) : ''} 203 | 204 |
    ${unsafeHTML(text)}
    205 |
    206 |
    207 | 208 | 209 | ${kids && commentTree(kids, post)} 210 | ${Promise.resolve(post.moreLink).then(ml => ml ? moreLinkEl(ml) : '')} 211 | 212 |
    213 | 214 | `; 215 | } catch (err) { 216 | return html` 217 | ${err instanceof Error ? err.message : err as string}` 218 | } 219 | }); 220 | let stream = asyncIterableToStream(content) 221 | // In safari, sending many small chunks to the chokes up the browser for some reason, 222 | // so we limit the amount of chunks sent via exponential grouping. 223 | if (isSafari(navigator.userAgent)) stream = stream.pipeThrough(new ExponentialJoinStream('')) 224 | return new StreamResponse(stream, { headers: [['content-type', HTMLResponse.contentType]]}) 225 | } 226 | 227 | router.get('/identicon/:by.svg', 228 | combine( 229 | basics(), 230 | contentTypes(['image/svg+xml', '*/*']), // FF does not include image/svg+xml in image request accept header... 231 | // caching({ 232 | // cacheControl: 'public', 233 | // maxAge: 31536000, 234 | // }) 235 | ), 236 | async (req, { params: { by: seed = '' }, waitUntil, handled }) => { 237 | const cache = await self.caches?.open('identicon'); 238 | const res = await cache?.match(req); 239 | 240 | if (!res) { 241 | const svg = new TextEncoder().encode(renderIconSVG({ seed, size: 6, scale: 2 })) 242 | const res = new Response(svg, { 243 | headers: { 244 | 'content-type': 'image/svg+xml', 245 | 'content-length': ''+svg.byteLength , 246 | // FIXME: Make it possible to get response (or just headers!?) after middleware applied!? 247 | // Avoid setting cache control manually 248 | 'cache-control': 'public, max-age=31536000', 249 | }, 250 | }); 251 | waitUntil((async () => { 252 | await handled; 253 | return cache?.put(req, res) 254 | })()); 255 | // Returning a clone of the response, because this response gets used first (thanks to `await handled`) 256 | return res.clone() 257 | } 258 | 259 | // FIXME: how to deal with immutable responses + middleware from cache?? 260 | return new Response(res.body, res) 261 | // return res; 262 | }, 263 | ) 264 | 265 | router.get('/item', mw, (_req, ctx) => getItem(ctx)) 266 | -------------------------------------------------------------------------------- /src/routes/manifest-handler.js: -------------------------------------------------------------------------------- 1 | import * as assets from './assets.ts'; 2 | import { default as DeviceDetector } from "device-detector-js" 3 | 4 | export async function manifestHandler(request, { waitUntil }) { 5 | const userAgent = request.headers.get('user-agent'); 6 | const device = userAgent && new DeviceDetector({ skipBotDetection: true }).parse(userAgent); 7 | const response = await assets.handler(request, { request, waitUntil }) 8 | const manifest = await response.json() 9 | 10 | if (device?.os && manifest.icons?.some(i => !!i.__os)) { 11 | manifest.icons = manifest.icons?.filter(i => i.__os === device.os?.name || i.__os?.toLowerCase() === 'any') 12 | } else { 13 | manifest.icons = manifest.icons?.filter(i => !i.__os || i.__os?.toLowerCase() === 'any'); 14 | } 15 | manifest.icons = manifest.icons?.map(({ __os, ...props }) => ({ ...props })); 16 | 17 | return new Response(JSON.stringify(manifest, null, 2), response); 18 | } -------------------------------------------------------------------------------- /src/routes/news.ts: -------------------------------------------------------------------------------- 1 | import { html, HTMLContent, HTMLResponse, unsafeHTML } from "@worker-tools/html"; 2 | import { basics, combine, contentTypes } from "@worker-tools/middleware"; 3 | import { notFound, ok } from "@worker-tools/response-creators"; 4 | import { formatDistanceToNowStrict } from 'date-fns'; 5 | import { fromUrl, parseDomain } from 'parse-domain'; 6 | import { JSONStreamResponse, jsonStringifyGenerator } from '@worker-tools/json-stream' 7 | import { JSONRequest } from "@worker-tools/json-fetch"; 8 | import { location } from '../location.ts'; 9 | 10 | import { router, RouteArgs, mw } from "../router.ts"; 11 | import { cachedWarning, del, favicon, identicon, pageLayout } from './components.ts'; 12 | 13 | import { api, APost, Stories, StoriesParams, StoriesData } from '../api/index.ts' 14 | import { StreamResponse } from "@worker-tools/stream-response"; 15 | 16 | // For some sites (which?) HN shows the subdomain. Here are some that I've discovered... 17 | const SUB_SITES = [ 18 | 'github.io', 19 | 'gitlab.io', 20 | 'medium.com', 21 | 'substack.com', 22 | 'mozilla.org', 23 | 'mit.edu', 24 | 'hardvard.edu', 25 | 'google.com', 26 | 'apple.com', 27 | 'notion.site', 28 | 'js.org', 29 | 'bearblog.dev', 30 | 'free.fr', 31 | 'bl.uk', 32 | 'azurewebsites.net', 33 | 'wordpress.com', 34 | 'blogspot.com', 35 | 'posthaven.com', 36 | 'twitter.com', 37 | ] as const; 38 | 39 | // Sites that are like GitHub, where HN shows the first path segment after the domain, e.g. github.com/qwtel 40 | const GIT_SITES = ['github.com', 'gitlab.com', 'twitter.com', 'vercel.app', 'bitbucket.org'] as const; 41 | 42 | // Sites that are like Forbes, where HN shows two path segment after the domain 43 | const FORBES_SITES = ['www.forbes.com'] as const 44 | 45 | // HACK: Sends a whitespace character immediately to let the frontend know that the backend has responded. 46 | export async function* fastTTFB(iter: AsyncIterable) { 47 | yield ' ' 48 | yield* iter; 49 | } 50 | 51 | const tryURL = (href: string): (URL & { sitebit?: string }) | null => { 52 | try { 53 | const url = new URL(href, location.origin); 54 | const res = parseDomain(url.hostname)! 55 | if (res.type === 'LISTED') { 56 | const { domain, topLevelDomains: tld, subDomains } = res; 57 | const allowedSubDomains = SUB_SITES.some(_ => url.hostname.endsWith(_)) && subDomains.length 58 | ? subDomains.slice(subDomains.length - 1).concat('').join('.') 59 | : '' 60 | 61 | const allowedPathname = GIT_SITES.includes(url.hostname as any) 62 | ? url.pathname.split(/\/+/).slice(0, 2).join('/').toLowerCase() 63 | : FORBES_SITES.includes(url.hostname as any) 64 | ? url.pathname.split(/\/+/).slice(0, 3).join('/').toLowerCase() 65 | : ''; 66 | 67 | const sitebit = `${allowedSubDomains}${domain}.${tld.join('.')}${allowedPathname}`; 68 | return Object.assign(url, { sitebit }); 69 | } 70 | return null 71 | } catch { return null } 72 | } 73 | 74 | const rankEl = (index?: number) => html` 75 | ${index != null && !Number.isNaN(index) ? `${index + 1}.` : ''}`; 76 | 77 | export const aThing = ({ type, id, url: href, title, dead, deleted }: APost, index?: number, op?: Stories) => { 78 | try { 79 | const url = tryURL(href); 80 | const upVoted = false // session?.votes.has(id); 81 | return html` 82 | 83 | ${rankEl(index)} 84 |
    ${type === 'job' 85 | ? html`` 86 | : upVoted 87 | ? '' 88 | : html`
    ` 89 | }
    90 | ${deleted 91 | ? '[deleted]' 92 | : html`${favicon(url)} ${title}${url?.host === location.host 93 | ? '' 94 | : url 95 | ? html` (${url.sitebit})` 96 | : ''}` 97 | }`; 98 | } catch (err) { 99 | throw html`Something went wrong${err instanceof Error ? err.message : err as string}` 100 | } 101 | } 102 | 103 | export const subtext = (post: APost, index?: number, op?: Stories, { showPast = false }: { showPast?: boolean } = {}) => { 104 | const { type, id, title, time, score, by, descendants, dead } = post; 105 | const timeAgo = time && formatDistanceToNowStrict(new Date(time), { addSuffix: true }) 106 | return html` 107 | 108 | 109 | 110 | ${!dead && type !== 'job' 111 | ? html`${score} points by` 112 | : ''} 113 | ${type !== 'job' 114 | ? html`${showPast ? identicon(by, 9): ''} ${by}` 115 | : ''} 116 | ${timeAgo} 117 | 118 | ${showPast 119 | ? html`| past` 120 | : ''} 121 | 122 | 123 | ${!dead && type !== 'job' 124 | ? html`| ${descendants === 0 125 | ? 'discuss' 126 | : unsafeHTML(`${descendants} comments`)}` 127 | : ''} 128 | ${SW && self.caches?.open('comments').then(cache => cache.match(new JSONRequest(`item?id=${id}`))) 129 | .then(x => x && html`| Offline ✓`)} 130 | 131 | 132 | `; 133 | } 134 | 135 | const rowEl = (post: APost, i: number, type: Stories) => { 136 | const index = [Stories.JOB, Stories.FROM].includes(type) ? NaN : i; 137 | return html` 138 | ${aThing(post, index, type)} 139 | ${subtext(post, index, type)} 140 | `; 141 | } 142 | 143 | const typesToTitles = { 144 | [Stories.TOP]: '', 145 | [Stories.JOB]: 'jobs', // sic! 146 | [Stories.ASK]: 'Ask', 147 | [Stories.BEST]: 'Top Links', 148 | [Stories.NEW]: 'New Links', 149 | [Stories.SHOW]: 'Show', 150 | [Stories.SHOW_NEW]: 'New Show', 151 | [Stories.USER]: `$user's submissions`, 152 | [Stories.CLASSIC]: '', 153 | [Stories.FROM]: 'Submissions from $site', 154 | [Stories.OFFLINE]: '' 155 | } 156 | 157 | const messageEl = (message: HTMLContent, marginBottom = 12) => html` 158 | 159 | ${message} 160 | `; 161 | 162 | const toTime = (r: Response) => new Date(r.headers.get('date')!).getTime() 163 | 164 | async function offlineStories({ p }: { p: number }): Promise { 165 | const cache = await self.caches.open('comments') 166 | const keys = await cache.keys() 167 | // FIXME: should probably manage an index in indexeddb 168 | const responses = await Promise.all(keys.map(async key => (await cache.match(key))!)) 169 | const items = await Promise.all(responses 170 | .sort((a, b) => toTime(b) - toTime(a)) 171 | .slice(PAGE * (p - 1), PAGE * p) 172 | .filter(res => res.ok) 173 | .map(res => res.json().catch(() => null) as Promise) 174 | .filter(json => json != null) 175 | ); 176 | const moreLink = PAGE * p < responses.length ? `offline?p=${p + 1}` : '' 177 | return { items, moreLink } 178 | } 179 | 180 | const PAGE = 30 181 | const mkStories = (type: Stories) => ({ request, headers, searchParams, type: contentType, url, handled, waitUntil }: RouteArgs) => { 182 | const p = Math.max(1, Number(searchParams.get('p') || '1')); 183 | if (p > Math.ceil(500 / 30)) return notFound('Not supported by Worker News'); 184 | const next = Number(searchParams.get('next')) 185 | const n = Number(searchParams.get('n')) 186 | const id = Stories.USER ? searchParams.get('id')! : ''; 187 | const site = Stories.FROM ? searchParams.get('site')! : ''; 188 | 189 | const title = typesToTitles[type] 190 | .replace('$user', searchParams.get('id')!) 191 | .replace('$site', searchParams.get('site')!) 192 | 193 | const storiesPage = type === Stories.OFFLINE 194 | ? offlineStories({ p }) 195 | : api.stories({ p, n, next, id, site }, type, { url, handled, waitUntil }); 196 | 197 | if (contentType === 'application/json') { 198 | return new StreamResponse(fastTTFB(jsonStringifyGenerator(storiesPage)), { 199 | headers: [['content-type', JSONStreamResponse.contentType]] 200 | }) 201 | } 202 | 203 | return new HTMLResponse(pageLayout({ op: type, title, id: searchParams.get('id')!, headers })(html` 204 | 205 | 206 | 207 | 208 | 209 | ${type === Stories.SHOW ? messageEl(html` 210 | Please read the rules. You can also 211 | browse the newest Show HNs.`) : ''} 212 | ${type === Stories.JOB ? messageEl(html` 213 | These are jobs at YC startups. See more at 214 | ycombinator.com/jobs.`, 14) : ''} 215 | ${type === Stories.OFFLINE ? messageEl(html` 216 | These are stories cached for offline reading.`) : ''} 217 | ${async function* () { 218 | try { 219 | let i = (next && n) 220 | ? (n - 1) 221 | : (p - 1) * 30; 222 | const { items, moreLink } = await storiesPage; 223 | yield cachedWarning(await storiesPage, request) 224 | for await (const post of items) { 225 | yield rowEl(post, i++, type); 226 | } 227 | if (await moreLink) { 228 | yield html` 229 | 230 | 231 | 232 | `; 233 | } 234 | } catch (err) { 235 | yield html``; 236 | } 237 | }} 238 | 239 |
    ${err instanceof Error ? err.message : err as string}
    240 | 241 | `)); 242 | }; 243 | 244 | router.get('/favicon/:hostname.ico', basics(), async (req, { params, waitUntil, handled }) => { 245 | const cache = await self.caches?.open('favicon') 246 | const res = await cache?.match(req) 247 | if (!res) { 248 | let res2 = await fetch(SW ? req.url : `https://icons.duckduckgo.com/ip3/${params.hostname}.ico`, req) 249 | if (res2.status === 404) { 250 | res2 = new Response(res2.body, { ...res2, status: 200 }) 251 | } 252 | waitUntil((async () => { 253 | await handled; 254 | if (res2.ok) await cache?.put(req, res2) 255 | })()); 256 | 257 | return res2.clone(); 258 | } 259 | return res; 260 | }) 261 | 262 | export const news = mkStories(Stories.TOP) 263 | export const newest = mkStories(Stories.NEW) 264 | export const best = mkStories(Stories.BEST) 265 | export const show = mkStories(Stories.SHOW) 266 | export const showNew = mkStories(Stories.SHOW_NEW) 267 | export const ask = mkStories(Stories.ASK) 268 | export const jobs = mkStories(Stories.JOB) 269 | export const submitted = mkStories(Stories.USER) 270 | export const classic = mkStories(Stories.CLASSIC) 271 | export const from = mkStories(Stories.FROM) 272 | export const offline = mkStories(Stories.OFFLINE) 273 | 274 | router.get('/news', mw, (_req, ctx) => news(ctx)) 275 | router.get('/newest', mw, (_req, x) => newest(x)); 276 | router.get('/best', mw, (_req, x) => best(x)); 277 | router.get('/show', mw, (_req, x) => show(x)) 278 | router.get('/shownew', mw, (_req, x) => showNew(x)) 279 | router.get('/ask', mw, (_req, x) => ask(x)) 280 | router.get('/jobs', mw, (_req, x) => jobs(x)) 281 | router.get('/submitted', mw, (_req, x) => submitted(x)) 282 | router.get('/classic', mw, (_req, x) => classic(x)) 283 | router.get('/from', mw, (_req, x) => from(x)) 284 | 285 | if (SW) { 286 | router.get('/offline', mw, (_req, x) => offline(x)) 287 | } 288 | -------------------------------------------------------------------------------- /src/routes/threads.ts: -------------------------------------------------------------------------------- 1 | import { html, unsafeHTML, HTMLResponse, HTMLContent } from "@worker-tools/html"; 2 | import { notFound } from "@worker-tools/response-creators"; 3 | import { basics, combine, contentTypes } from "@worker-tools/middleware"; 4 | import { JSONStreamResponse, jsonStringifyGenerator } from '@worker-tools/json-stream' 5 | 6 | import { router, RouteArgs, mw } from "../router.ts"; 7 | 8 | import { api } from "../api/index.ts"; 9 | 10 | import { pageLayout } from './components.ts'; 11 | import { commentEl } from "./item.ts"; 12 | import { fastTTFB } from "./news.ts"; 13 | import { StreamResponse } from "@worker-tools/stream-response"; 14 | 15 | export const moreLinkEl = (moreLink: string) => html` 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
    26 | 27 | `; 28 | 29 | function threads({ headers, searchParams, type: contentType, url, handled, waitUntil }: RouteArgs) { 30 | const id = searchParams.get('id'); 31 | if (!id) return notFound('No such item.'); 32 | const title = `${id}'s comments`; 33 | 34 | const next = Number(searchParams.get('next')); 35 | 36 | const threadsPage = api.threads(id, next, { url, handled, waitUntil }); 37 | 38 | if (contentType === 'application/json') { 39 | return new StreamResponse(fastTTFB(jsonStringifyGenerator(threadsPage)), { 40 | headers: [['content-type', JSONStreamResponse.contentType]] 41 | }) 42 | } 43 | 44 | return new HTMLResponse(pageLayout({ title, op: 'threads', id, headers })(() => { 45 | return html` 46 | 47 | 48 | 49 | 50 | ${async function* () { 51 | try { 52 | const { items, moreLink } = await threadsPage 53 | for await (const item of items) { 54 | yield commentEl(item, { showReply: true, showParent: item.level === 0 }); 55 | } 56 | yield moreLinkEl(await moreLink); 57 | } catch (err) { 58 | console.warn(err) 59 | yield html`${err instanceof Error ? err.message : err as string}`; 60 | } 61 | }} 62 | `; 63 | })); 64 | } 65 | 66 | router.get('/threads', mw, (_req, x) => threads(x)) 67 | -------------------------------------------------------------------------------- /src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import { html, HTMLResponse, unsafeHTML } from "@worker-tools/html"; 2 | import { notFound } from "@worker-tools/response-creators"; 3 | import { JSONStreamResponse, jsonStringifyGenerator } from "@worker-tools/json-stream"; 4 | 5 | import { router, RouteArgs, mw } from "../router.ts"; 6 | 7 | import { api } from "../api/index.ts"; 8 | import { pageLayout, identicon } from './components.ts'; 9 | import { fastTTFB } from "./news.ts"; 10 | import { StreamResponse } from "@worker-tools/stream-response"; 11 | 12 | const dtf = new Intl.DateTimeFormat('en-US', { 13 | year: 'numeric', 14 | month: 'long', 15 | day: 'numeric', 16 | }); 17 | 18 | const numDTF = new Intl.DateTimeFormat('en-US', { 19 | year: 'numeric', 20 | month: 'numeric', 21 | day: 'numeric', 22 | }); 23 | 24 | const user = ({ headers, searchParams, type, url, handled, waitUntil }: RouteArgs) => { 25 | const un = searchParams.get('id'); 26 | if (!un) return notFound('No such user.'); 27 | 28 | const userPromise = api.user(un, { url, handled, waitUntil }) 29 | const title = `Profile: ${un}`; 30 | 31 | if (type === 'application/json') { 32 | return new StreamResponse(fastTTFB(jsonStringifyGenerator(userPromise)), { 33 | headers: [['content-type', JSONStreamResponse.contentType]] 34 | }) 35 | } 36 | 37 | return new HTMLResponse(pageLayout({ op: 'user', title, headers })(html` 38 | 39 | 40 | 41 | 42 | ${async () => { 43 | try { 44 | const uo = await userPromise; 45 | const dt = typeof uo?.created === 'number' ? new Date(uo.created * 1000) : new Date(uo.created ?? 0) 46 | const [{ value: month },, { value: day },, { value: year }] = numDTF.formatToParts(dt); 47 | return html` 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | `; 56 | } catch (err) { 57 | return html``; 58 | } 59 | }} 60 |
    user:${identicon(un, 13)} ${un}
    created:${dtf.format(dt)}
    karma:${uo.karma}
    about:${unsafeHTML(uo.about ?? '')}
    submissions
    comments
    ${err instanceof Error ? err.message : err as string}
    61 |

    62 | 63 | `)); 64 | } 65 | 66 | router.get('/user', mw, (_req, x) => user(x)) 67 | -------------------------------------------------------------------------------- /src/stubs/device-detector.ts: -------------------------------------------------------------------------------- 1 | export default class DeviceDetector {} -------------------------------------------------------------------------------- /src/stubs/firebase.ts: -------------------------------------------------------------------------------- 1 | export function initializeApp(...args: any[]): any {} 2 | export function getDatabase(...args: any[]): any {} 3 | export function ref(...args: any[]): any {} 4 | export function onValue(...args: any[]): any {} -------------------------------------------------------------------------------- /src/stubs/linkedom.ts: -------------------------------------------------------------------------------- 1 | export class DOMParser {} -------------------------------------------------------------------------------- /src/stubs/perf_hooks.js: -------------------------------------------------------------------------------- 1 | const { 2 | performance, 3 | Performance, 4 | PerformanceEntry, 5 | PerformanceMark, 6 | PerformanceMeasure, 7 | PerformanceNavigation, 8 | PerformanceNavigationTiming, 9 | PerformanceObserver, 10 | PerformanceObserverEntryList, 11 | PerformanceResourceTiming, 12 | PerformanceTiming, 13 | } = globalThis; 14 | 15 | export { 16 | performance, 17 | Performance, 18 | PerformanceEntry, 19 | PerformanceMark, 20 | PerformanceMeasure, 21 | PerformanceNavigation, 22 | PerformanceNavigationTiming, 23 | PerformanceObserver, 24 | PerformanceObserverEntryList, 25 | PerformanceResourceTiming, 26 | PerformanceTiming, 27 | }; 28 | -------------------------------------------------------------------------------- /src/vendor/aggregate-error.ts: -------------------------------------------------------------------------------- 1 | class AggregateErrorPolyfill extends Error { 2 | errors: readonly any[]; 3 | constructor(errors: Iterable, message = '') { 4 | super(message); 5 | this.errors = [...errors]; 6 | this.name = 'AggregateError'; 7 | } 8 | } 9 | 10 | export const AggregateError: typeof AggregateErrorPolyfill = 'AggregateError' in globalThis 11 | ? (globalThis).AggregateError 12 | : AggregateErrorPolyfill -------------------------------------------------------------------------------- /src/vendor/async-queue.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | 3 | // Based on node.js event utility: 4 | // Copyright Joyent, Inc. and other Node contributors. 5 | // Licensed under the MIT license 6 | 7 | type Resolver = (value: T | PromiseLike) => void; 8 | type Rejecter = (reason?: any) => void; 9 | 10 | function newAbortError() { 11 | return new DOMException('eventTargetToAsyncIter was aborted via AbortSignal', 'AbortError'); 12 | } 13 | 14 | export interface AsyncQueueOptions { 15 | /** 16 | * An abort signal to cancel async iteration. 17 | */ 18 | signal?: AbortSignal, 19 | 20 | // underlyingSource?: UnderlyingSource, 21 | } 22 | 23 | /** 24 | * A queue implementation that delivers values asynchronously. 25 | * Useful for implementing various async iterable functionality. 26 | * 27 | * Producers can provide values to the queue via `push`, while consumers can request them via `shift`. 28 | * Unlike regular JS arrays, `shift` returns a promise that resolves with the next value in the queue. 29 | * If the queue is currently empty, the promise will resolve once a new value gets pushed into the queue. 30 | * Repeated calls to shift with an empty queue will deliver results in the order they have been requested. 31 | * 32 | * The queue also implements the async iterable interface, meaning it can be used in a for-await loop: 33 | * 34 | * ``` 35 | * const q = new AsyncQueue() 36 | * q.push(1, 2, 3) 37 | * for await (const value of q) { 38 | * // ... 39 | * } 40 | * ``` 41 | */ 42 | export class AsyncQueue implements AsyncIterableIterator, ReadableStreamDefaultController { 43 | #unconsumedValues: T[] = []; 44 | #unconsumedPromises: { resolve: Resolver>, reject: Rejecter }[] = []; 45 | #signal?: AbortSignal; 46 | #error?: any = null; 47 | #finished = false; 48 | // #src?: UnderlyingSource; 49 | 50 | /** @deprecated TODO */ 51 | get desiredSize() { return 0 } 52 | 53 | constructor(options?: AsyncQueueOptions) { 54 | const signal = options?.signal; 55 | if (signal?.aborted) throw newAbortError(); 56 | if (signal) { 57 | signal.addEventListener('abort', this.#abortListener, { once: true }); 58 | } 59 | this.#signal = signal 60 | // this.#src = options?.underlyingSource; 61 | // options?.underlyingSource?.start?.(this); 62 | } 63 | 64 | #errorHandler = (err: any) => { 65 | this.#finished = true; 66 | 67 | const toError = this.#unconsumedPromises.shift(); 68 | 69 | if (toError) { 70 | toError.reject(err); 71 | } else { 72 | // The next time we call next() 73 | this.#error = err; 74 | } 75 | 76 | this.return(); 77 | } 78 | 79 | #abortListener = () => { 80 | this.#errorHandler(newAbortError()); 81 | } 82 | 83 | #push(value: T) { 84 | const promise = this.#unconsumedPromises.shift(); 85 | if (promise) { 86 | promise.resolve({ value, done: false }); 87 | } else { 88 | this.#unconsumedValues.push(value); 89 | } 90 | } 91 | 92 | enqueue(item: T) { 93 | this.#push(item); 94 | } 95 | 96 | async dequeue(): Promise { 97 | const { done, value } = await this.next(); 98 | return done ? undefined : value; 99 | } 100 | 101 | next(): Promise> { 102 | // First, we consume all unread events 103 | const value = this.#unconsumedValues.shift(); 104 | if (value) { 105 | return Promise.resolve({ value, done: false }); 106 | } 107 | 108 | // Then we error, if an error happened 109 | // This happens one time if at all, because after 'error' 110 | // we stop listening 111 | if (this.#error) { 112 | const p = Promise.reject(this.#error); 113 | // Only the first element errors 114 | this.#error = null; 115 | return p; 116 | } 117 | 118 | // If the iterator is finished, resolve to done 119 | if (this.#finished) { 120 | return Promise.resolve({ value: undefined, done: true }); 121 | } 122 | 123 | // Wait until an event happens 124 | return new Promise((resolve, reject) => { 125 | this.#unconsumedPromises.push({ resolve, reject }); 126 | 127 | // FIXME: should it keep calling pull until the size is 1 again?? 128 | // Compare with streams spec 129 | // if (typeof this.#src?.pull === 'function') (async () => { 130 | // while (this.size < 0) await this.#src?.pull?.(this) 131 | // })(); 132 | }); 133 | } 134 | 135 | /** 136 | * Get the length of the queue. 137 | * _Note that the length can be negative_, meaning more values have been requested than can be provided. 138 | */ 139 | get size() { 140 | return this.#unconsumedValues.length - this.#unconsumedPromises.length 141 | } 142 | 143 | return(): Promise> { 144 | if (this.#signal) { 145 | this.#signal.removeEventListener('abort', this.#abortListener); 146 | } 147 | 148 | this.#finished = true; 149 | 150 | for (const promise of this.#unconsumedPromises) { 151 | promise.resolve({ value: undefined, done: true }); 152 | } 153 | 154 | return Promise.resolve({ value: undefined, done: true }); 155 | } 156 | 157 | close(): void { 158 | this.return() 159 | } 160 | 161 | throw(err: any): Promise> { 162 | this.#error = err; 163 | return Promise.reject(err) 164 | } 165 | 166 | error(err: any): void { 167 | this.throw(err) 168 | } 169 | 170 | [Symbol.asyncIterator](): AsyncIterableIterator { 171 | return this; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/vendor/awaited-values.ts: -------------------------------------------------------------------------------- 1 | import type { Awaitable } from './common-types.ts' 2 | 3 | /** Force intellisense to expand the typing to hide merging typings */ 4 | export type ExpandRecursively = T extends Record 5 | ? T extends infer O ? { [K in keyof O]: ExpandRecursively } : never 6 | : T; 7 | 8 | /** 9 | * Takes a record `T` and for all its keys `K` turns the corresponding values into `Promise`. 10 | */ 11 | // FIXME: better way to retain readonly and optional modifiers!??? 12 | export type PromisedIn = ExpandRecursively<{ 13 | [Property in keyof T]: Property extends Incl ? Awaitable : T[Property] 14 | }> 15 | 16 | export type PromisedEx = ExpandRecursively<{ 17 | [Property in keyof T]: Property extends Excl ? T[Property] : Awaitable 18 | }> 19 | 20 | /** 21 | * Inverse of `PromisedValues`. Takes a record `T` and for its keys `K` turns the corresponding values into `Awaited` 22 | */ 23 | // FIXME: better way to retain readonly and optional modifiers!??? 24 | export type AwaitedValuesEx = ExpandRecursively<{ 25 | [Prop in keyof T]: Prop extends Excl ? T[Prop] : Awaited 26 | }> 27 | export type AwaitedValuesIn = ExpandRecursively<{ 28 | [Prop in keyof T]: Prop extends Incl ? Awaited : T[Prop]; 29 | }> 30 | 31 | type Rec = Record; 32 | 33 | /** 34 | * Lifts all direct properties of `obj` that are promises to the parent. 35 | * E.g. 36 | * ``` 37 | * { foo: Promise<"bar"> } => Promise<{ foo: "bar" }> 38 | * ``` 39 | * @deprecated Change name 40 | */ 41 | export async function liftAsync< 42 | T extends Rec, 43 | Incl extends keyof T = never, 44 | Excl extends keyof T = never 45 | >( 46 | obj: T, 47 | opts: { include?: Incl[] } 48 | ): Promise>; 49 | 50 | export async function liftAsync< 51 | T extends Rec, 52 | Incl extends keyof T = never, 53 | Excl extends keyof T = never 54 | >( 55 | obj: T, 56 | opts: { exclude?: Excl[] }, 57 | ): Promise>; 58 | 59 | export async function liftAsync< 60 | T extends Rec, 61 | Incl extends keyof T = never, 62 | Excl extends keyof T = never 63 | >( 64 | obj: T, 65 | { include, exclude }: { include?: Incl[], exclude?: Excl[] } = {} 66 | ): Promise { 67 | if (exclude && include) 68 | throw TypeError('Can\'t be include and exclude keys at the same time'); 69 | 70 | if (exclude) { 71 | const props = (Object.keys(obj)).filter(x => !exclude.includes(x)) 72 | const results = await Promise.all(props.map(async prop => [prop, await obj[prop]] as const)) 73 | for (const [prop, awaited] of results) { obj[prop] = awaited } 74 | } 75 | 76 | else if (include) { 77 | const props = (Object.keys(obj)).filter(x => include.includes(x)) 78 | const results = await Promise.all(props.map(async prop => [prop, await obj[prop]] as const)) 79 | for (const [prop, awaited] of results) { obj[prop] = awaited } 80 | } 81 | 82 | return obj as any 83 | } 84 | 85 | // type Getters = { 86 | // [Property in keyof Type as `get${Capitalize}`]: () => Type[Property] 87 | // }; 88 | 89 | // interface Person { 90 | // readonly name: string; 91 | // age: number; 92 | // location?: string; 93 | // __x: any 94 | // } 95 | 96 | // type LazyPerson = Getters; 97 | // type AwaitedPerson = AsPromised; 98 | 99 | // type KeysOfType = { [K in keyof T]: T[K] extends U ? K : never }[keyof T]; 100 | // type RequiredKeys = Exclude>, undefined>; 101 | // type OptionalKeys = Exclude>; 102 | 103 | 104 | // type RetainOptional = T extends undefined ? U | undefined : U 105 | -------------------------------------------------------------------------------- /src/vendor/common-types.ts: -------------------------------------------------------------------------------- 1 | export type Repeatable = T | T[]; 2 | export type Awaitable = T | PromiseLike; 3 | export type Callable = T | (() => T); 4 | export type Primitive = null | undefined | boolean | number | string | bigint | symbol; 5 | export type ToString = { toString(...args: any[]): string } 6 | 7 | // /** See: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm */ 8 | // export type StructuredCloneable = Omit 9 | // | Date 10 | // | RegExp 11 | // | Blob 12 | // | File 13 | // | FileList 14 | // | ArrayBuffer 15 | // | ArrayBufferView 16 | // | ImageBitmap 17 | // | ImageData 18 | // | StructuredCloneable[] 19 | // | Map 20 | // | Set 21 | -------------------------------------------------------------------------------- /src/vendor/custom-event-polyfill.ts: -------------------------------------------------------------------------------- 1 | /** Quick n' dirty CustomEvent polyfill for Worker Environments. */ 2 | 3 | self.navigator = self.navigator || { userAgent: 'Cloudflare Workers' } 4 | 5 | if (!('CustomEvent' in self)) { 6 | class CustomEvent extends Event { 7 | readonly detail: T; 8 | constructor(event: string, { detail }: CustomEventInit) { 9 | super(event); 10 | this.detail = detail as T; 11 | } 12 | } 13 | 14 | Object.defineProperty(self, 'CustomEvent', { 15 | configurable: false, 16 | enumerable: false, 17 | writable: false, 18 | value: CustomEvent 19 | }); 20 | } -------------------------------------------------------------------------------- /src/vendor/enumerable.ts: -------------------------------------------------------------------------------- 1 | import { append } from "./map-append.ts"; 2 | 3 | const descriptorMap = new WeakMap(); 4 | 5 | /** 6 | * Experimental TS decorator to mark class properties as enumerable. 7 | * When applied to the class itself, it will also make the properties enumerable _on instances themselves_! 8 | * 9 | * For mor on the difficulty of making getters enumerable, see: 10 | * https://stackoverflow.com/questions/34517538/setting-an-es6-class-getter-to-enumerable 11 | */ 12 | export function enumerable(obj: any, property: string, descriptor: PropertyDescriptor): void; 13 | export function enumerable(ctor: T): T; 14 | export function enumerable(obj: any, property?: string, descriptor?: PropertyDescriptor) { 15 | if (property && descriptor) { 16 | descriptor.enumerable = true; 17 | append(descriptorMap, obj, [property, descriptor]) 18 | } else { 19 | return class extends obj { 20 | constructor(...args: any[]) { 21 | super(...args); 22 | for (const [prop, desc] of descriptorMap.get(obj.prototype) ?? []) { 23 | Object.defineProperty(this, prop, desc); 24 | } 25 | } 26 | }; 27 | } 28 | } 29 | 30 | // function* prototypes(obj: any) { 31 | // let prototype = Object.getPrototypeOf(obj); 32 | // while (prototype && prototype !== Object.prototype) { 33 | // yield prototype; 34 | // prototype = Object.getPrototypeOf(prototype); 35 | // } 36 | // } 37 | -------------------------------------------------------------------------------- /src/vendor/map-append.ts: -------------------------------------------------------------------------------- 1 | export function append(m: Map, k: K, v: V): typeof m; 2 | export function append(m: WeakMap, k: K, v: V): typeof m; 3 | export function append(m: any, k: K, v: V) { 4 | const vs = m.get(k) ?? []; 5 | vs.push(v); 6 | return m.set(k, vs); 7 | } 8 | -------------------------------------------------------------------------------- /src/vendor/unsettle.ts: -------------------------------------------------------------------------------- 1 | import { AggregateError } from "./aggregate-error.ts"; 2 | 3 | export const isFulfilled = (r: PromiseSettledResult): r is PromiseFulfilledResult => { 4 | return r.status === 'fulfilled'; 5 | } 6 | export const isRejected = (r: PromiseSettledResult): r is PromiseRejectedResult => { 7 | return r.status === 'rejected'; 8 | } 9 | 10 | /** 11 | * Helper function that unwinds `Promise.allSettled`: 12 | * Takes the promise returned and throws a `CombinedError` iff at least one promise settled with a rejection. 13 | * Otherwise returns the list of fulfilled values. 14 | * @param allSettledPromise A promise returned by `Promise.allSettled` 15 | * @returns List of fulfilled values 16 | */ 17 | export const unsettle = async (allSettledPromise: Promise[]>): Promise => { 18 | const rs = await allSettledPromise; 19 | if (rs.every(isFulfilled)) return rs.map(r => r.value) 20 | throw new AggregateError(rs.filter(isRejected).map(r => r.reason), "One or more Promises in 'unsettle' were rejected"); 21 | } 22 | 23 | // type Awaitable = T | PromiseLike; 24 | // const NEVER = new Promise(() => {}); 25 | // function raceNonNullish( 26 | // iterable: Iterable> 27 | // ) { 28 | // const promises = [...iterable].map(_ => Promise.resolve(_)); 29 | // let { length } = promises; 30 | // const continueIfNullish = (value: T | undefined | null) => value != null 31 | // ? value 32 | // : --length > 0 33 | // ? NEVER 34 | // : undefined; 35 | // const candidates = promises.map(p => p.then(continueIfNullish)) 36 | // return Promise.race(candidates); 37 | // } 38 | 39 | // async function* raceAll( 40 | // iterable: Iterable> 41 | // ) { 42 | // const promises = new Map([...iterable].map(async (p, i) => [i, await p] as const).entries()); 43 | // for (const _ of promises) { 44 | // const [i, value] = await Promise.race(promises.values()); 45 | // promises.delete(i); 46 | // yield value; 47 | // } 48 | // } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@worker-tools/kv-storage": ["./node_modules/@worker-tools/cloudflare-kv-storage"], 6 | "perf_hooks": ["./src/stubs/perf_hooks.js"], 7 | "firebase/app": ["./src/stubs/firebase.js"], 8 | "firebase/database": ["./src/stubs/firebase.js"], 9 | }, 10 | "outDir": "./dist", 11 | "module": "ESNext", 12 | "target": "ES2022", 13 | "lib": [ 14 | "ES2021", 15 | "WebWorker", 16 | "ESNext.AsyncIterable", 17 | "DOM", 18 | "DOM.Iterable", 19 | ], 20 | "moduleResolution": "node", 21 | "experimentalDecorators": true, 22 | "preserveConstEnums": true, 23 | "sourceMap": true, 24 | "esModuleInterop": true, 25 | "strict": true, 26 | "strictFunctionTypes": true, 27 | "allowJs": true, 28 | "checkJs": true, 29 | "types": [ 30 | "@cloudflare/workers-types", 31 | ], 32 | }, 33 | "include": [ 34 | "typings/*.d.ts", 35 | "src/*.ts", 36 | "src/**/*.ts", 37 | ], 38 | "exclude": [ 39 | "node_modules/", 40 | "dist/", 41 | "**/dist/", 42 | "**/node_modules/", 43 | "**/worker/", 44 | "_*", 45 | "**/_*" 46 | ] 47 | } -------------------------------------------------------------------------------- /tsconfig.sw.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@worker-tools/kv-storage": ["./node_modules/@worker-tools/kv-storage-polyfill"], 6 | "perf_hooks": ["./src/stubs/perf_hooks.js"], 7 | "firebase/app": ["./src/stubs/firebase.js"], 8 | "firebase/database": ["./src/stubs/firebase.js"], 9 | "linkedom": ["./src/stubs/linkedom.ts"], 10 | "device-detector-js": ["./src/stubs/device-detector.ts"], 11 | }, 12 | "outDir": "./dist", 13 | "module": "ESNext", 14 | "target": "ES2018", 15 | "lib": [ 16 | "ES2021", 17 | "WebWorker", 18 | "ESNext.AsyncIterable", 19 | "DOM", 20 | "DOM.Iterable", 21 | ], 22 | "moduleResolution": "node", 23 | "experimentalDecorators": true, 24 | "preserveConstEnums": true, 25 | "sourceMap": true, 26 | "esModuleInterop": true, 27 | "strict": true, 28 | "strictFunctionTypes": true, 29 | "allowJs": true, 30 | "checkJs": true, 31 | "types": [ 32 | "@types/node", 33 | ], 34 | }, 35 | "include": [ 36 | "typings/*.d.ts", 37 | "src/*.ts", 38 | "src/**/*.ts", 39 | ], 40 | "exclude": [ 41 | "node_modules/", 42 | "dist/", 43 | "**/dist/", 44 | "**/node_modules/", 45 | "**/worker/", 46 | "_*", 47 | "**/_*" 48 | ] 49 | } -------------------------------------------------------------------------------- /typings/blockies.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@qwtel/blockies' { 2 | export function renderIconSVG(x: { seed: string, size: number, scale: number }): string 3 | } 4 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | declare var DEBUG: boolean; 2 | declare var AUTH: string; 3 | declare var SW: boolean; 4 | -------------------------------------------------------------------------------- /typings/negotiated.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'negotiated' { 2 | export function mediaTypes(h?: string|null): IterableIterator<{ type: string, params: string, weight: number, extensions: string }> 3 | export function charsets(h?: string|null): IterableIterator<{ charset: string, weight: number }> 4 | export function encodings(h?: string|null): IterableIterator<{ encoding: string, weight: number }> 5 | export function languages(h?: string|null): IterableIterator<{ language: string, weight: number }> 6 | export function transferEncodings(h?: string|null): IterableIterator<{ encoding: string, params: string, weight: number }> 7 | export function parameters(p?: string|null): IterableIterator<{ key: string, value: string }> 8 | } 9 | -------------------------------------------------------------------------------- /worker-news.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker-tools/worker-news/4821eb6ef354c16029831d942c818d43014035ab/worker-news.jpg -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "news-dev" 2 | main = "./dist/worker.js" 3 | account_id = "efb289061241436254219b424fcea4cf" 4 | workers_dev = true 5 | compatibility_date = "2022-10-01" 6 | compatibility_flags = ["html_rewriter_treats_esi_include_as_void_tag", "streams_enable_constructors", "transformstream_enable_standard_constructor"] 7 | 8 | [site] 9 | bucket = "./public" 10 | 11 | [build] 12 | command = "npm run build" 13 | 14 | [vars] 15 | WORKER_LOCATION = 'http://localhost:8787' 16 | 17 | [env.production] 18 | name = "worker-news" 19 | 20 | [env.production.vars] 21 | WORKER_LOCATION = 'https://worker-news.qwtel.workers.dev' 22 | --------------------------------------------------------------------------------