Git Product home page Git Product logo

worktop's Introduction

worktop
The next generation web framework for Cloudflare Workers

Features

  • Super lightweight
  • First-class TypeScript support
  • Custom Middleware Support
  • Well-organized submodules for à la carte functionality*
  • Includes Router with support for pattern definitions
  • Familiar Request-Response handler API
  • Supports async/await handlers
  • Fully treeshakable

*More to come!

Install

$ npm install --save worktop

Usage

Check out /examples for a list of working demos!

import { Router } from 'worktop';
import * as Cache from 'worktop/cache';
import { uid as toUID } from 'worktop/utils';
import { read, write } from 'worktop/kv';
import type { KV } from 'worktop/kv';

declare var DATA: KV.Namespace;

interface Message {
  id: string;
  text: string;
  // ...
}

// Initialize
const API = new Router();


API.add('GET', '/messages/:id', async (req, res) => {
  // Pre-parsed `req.params` object
  const key = `messages::${req.params.id}`;

  // Assumes JSON (can override)
  const message = await read<Message>(DATA, key);

  // Alter response headers directly
  res.setHeader('Cache-Control', 'public, max-age=60');

  // Smart `res.send()` helper
  // ~> automatically stringifies JSON objects
  // ~> auto-sets `Content-Type` & `Content-Length` headers
  res.send(200, message);
});


API.add('POST', '/messages', async (req, res) => {
  try {
    // Smart `req.body` helper
    // ~> parses JSON header as JSON
    // ~> parses form-like header as FormData, ...etc
    var input = await req.body<Message>();
  } catch (err) {
    return res.send(400, 'Error parsing request body');
  }

  if (!input || !input.text.trim()) {
    return res.send(422, { text: 'required' });
  }

  const value: Message = {
    id: toUID(16),
    text: input.text.trim(),
    // ...
  };

  // Assumes JSON (can override)
  const key = `messages::${value.id}`;
  const success = await write<Message>(DATA, key, value);
  //    ^ boolean

  // Alias for `event.waitUntil`
  // ~> queues background task (does NOT delay response)
  req.extend(
    fetch('https://.../logs', {
      method: 'POST',
      headers: { 'content-type': 'application/json '},
      body: JSON.stringify({ success, value })
    })
  );

  if (success) res.send(201, value);
  else res.send(500, 'Error creating record');
});


API.add('GET', '/alive', (req, res) => {
  res.end('OK'); // Node.js-like `res.end`
});


// Attach "fetch" event handler
// ~> use `Cache` for request-matching, when permitted
// ~> store Response in `Cache`, when permitted
Cache.listen(API.run);

API

Module: worktop

View worktop API documentation

The main module – concerned with routing.
This is core of most applications. Exports the Router class.

Module: worktop/kv

View worktop/kv API documentation

The worktop/kv submodule contains all classes and utilities related to Workers KV.

Module: worktop/cache

View worktop/cache API documentation

The worktop/cache submodule contains all utilities related to Cloudflare's Cache.

Module: worktop/request

View worktop/request API documentation

The worktop/request submodule contains the ServerRequest class, which provides an interface similar to the request instance(s) found in most other Node.js frameworks.

Note: This module is used internally and will (very likely) never be imported by your application.

Module: worktop/response

View worktop/response API documentation

The worktop/response submodule contains the ServerResponse class, which provides an interface similar to the IncomingMessage (aka, "response") object that Node.js provides.

Note: This module is used internally and will (very likely) never be imported by your application.

Module: worktop/base64

View worktop/base64 API documentation

The worktop/base64 submodule contains a few utilities related to the Base 64 encoding.

Module: worktop/cookie

View worktop/cookie API documentation

The worktop/cookie submodule contains parse and stringify utilities for dealing with cookie header(s).

Module: worktop/cors

View worktop/cors API documentation

The worktop/cors submodule offers utilities for dealing with Cross-Origin Resource Sharing (CORS) headers.

Module: worktop/crypto

View worktop/crypto API documentation

The worktop/crypto submodule is a collection of cryptographic functionalities.

Module: worktop/utils

View worktop/utils API documentation

The worktop/utils submodule is a collection of standalone, general-purpose utilities that you may find useful. These may include – but are not limited to – hashing functions and unique identifier generators.

Module: worktop/ws

View worktop/ws API documentation

The worktop/ws submodule contains the WebSocket and WebSocketPair class definitions, as well as two middleware handlers for validating and/or setting up a SocketHandler for the WebSocket connection.

License

MIT © Luke Edwards

worktop's People

Contributors

cometkim avatar dummdidumm avatar jakechampion avatar lukeed avatar maraisr avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

worktop's Issues

Add explicit type safety to `compose` utility

As of #23, the compose export now exists. However, it only has basic req.params value forwarding.

This issue remains open so that compose can (*should) guard against Handlers that are loaded before their required properties have been satisfied.

For example:

import { compose } from 'worktop';

import type { Handler } from 'worktop';
import type { ServerRequest } from 'worktop/request';
import type { User, App } from 'lib/models';

type UserRequest = ServerRequest & { user: User };
type AppRequest = ServerRequest & { app: App };

// Assume it reads `Authorization` header, and loads `req.user` or bails
// ~> only "needs" a bare `ServerRequest`, provides a `UserRequest`
// HINT: Read the type argument left-to-right for Request mutation
declare const toUser: Handler<ServerRequest, UserRequest>;

// Assume it reads loads the relevant `App` record
// ~> only "needs" a bare `ServerRequest`, provides a `AppRequest`
declare const toApp: Handler<ServerRequest, AppRequest>;

// Validates that `req.user` is the owner of `req.app` record
// ~> NEEDS `req.user` & `req.app` to exist, provides no other changes
declare const isOwner: Handler<UserRequest & AppRequest>;

// @ts-expect-error :: no `AppRequest` involved
compose(toUser, isOwner);

// @ts-expect-error :: no `UserRequest` involved
compose(toApp, isOwner);

// @ts-expect-error :: isOwner before `UserRequest` defined
compose(toApp, isOwner, toUser);

// @ts-expect-error :: isOwner before `AppRequest` defined
compose(toUser, isOwner, toApp);

// TYPE SAFE 🎉 
compose(toUser, toApp, isOwner);

Of all the examples above, only the last line is (*should be) considered "type safe" because both req.user and req.app are defined before isOwner runs, which is a function that needs both of those properties to exist.

v0.7.0 and greater fail to start

Projects created with wrangler and worktop v0.7.0 and greater fail to run on macOS (Apple M1 and Intel). Details and steps to reproduce follow.

The problem appears to be related to:

Steps to recreate

In an empty directory:

  1. npm init --yes
  2. wrangler init
  3. Paste the worktop basic example code into a new file index.js
  4. npm install [email protected]
  5. wrangler dev

At this point the Cloudflare Workers development environment starts up and serves your API at 127.0.0.1:8787. Everything works as expected.

In the same directory:

  1. npm install [email protected] (or 0.7.1-0.7.3)
  2. wrangler dev

Wrangler fails with the following:

➜  ~ wrangler dev
👀  ./node_modules/worktop/router/index.mjs 80:36-37
Can't import the named export 'parse' from non EcmaScript module (only default export is available)
    at HarmonyImportSpecifierDependency._getErrors (/Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/dependencies/HarmonyImportSpecifierDependency.js:88:6)
    at HarmonyImportSpecifierDependency.getErrors (/Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/dependencies/HarmonyImportSpecifierDependency.js:68:16)
    at Compilation.reportDependencyErrorsAndWarnings (/Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/Compilation.js:1463:22)
    at /Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/Compilation.js:1258:10
    at AsyncSeriesHook.eval [as callAsync] (eval at create (/Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:24:1)
    at AsyncSeriesHook.lazyCompileHook (/Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/tapable/lib/Hook.js:154:20)
    at Compilation.finish (/Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/Compilation.js:1253:28)
    at /Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/Compiler.js:672:17
    at eval (eval at create (/Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:11:1)
    at /Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/Compilation.js:1185:12
    at /Users/rob/Library/Caches/.wrangler/wranglerjs-1.19.0/node_modules/webpack/lib/Compilation.js:1097:9
    at processTicksAndRejections (internal/process/task_queues.js:77:11)
 @ ./index.js
Error: webpack returned an error. You may be able to resolve this issue by running npm install.

Versions where issue observed:

  • OS: macOS Big Sur v11.5.2
  • Chip: Apple M1
  • Node.js: v14.17.6 (latest LTS at time of filing issue), v15.14.0
  • Wrangler: v1.19.0, v1.19.2
  • Worktop: v0.7.0, v0.7.1, v0.7.2, v0.7.3

Second machine (I don't have access to get complete version info):

  • OS: macOS
  • Chip: Intel
  • Node.js: v16.4.0

Support for Durable Objects

According to this page, it looks Cloudflare is replacing addEventHandler("fetch", event => { ... }) for a module syntax that exports async fetch(request, env) {? To work with durable objects it looks like that env is needed, and thus it looks like new module syntax would have to be used in worktop instead of the listen that's currently happening?

Using @cloudflare/workers-types produce error with worktop

tsconfig.json

{
  "compilerOptions": {
    "outDir": "build",
    "module": "commonjs",
    "target": "esnext",
    "lib": ["esnext", "webworker"],
    "alwaysStrict": true,
    "strict": true,
    "preserveConstEnums": true,
    "moduleResolution": "node",
    "sourceMap": true,
    "esModuleInterop": true,
    "types": ["@cloudflare/workers-types"]
  },
  "include": [
    "./src/**/*.ts"
  ],
  "exclude": ["node_modules/", "dist/"]
}
> tsc --noEmit

node_modules/worktop/request/index.d.ts:5:3 - error TS2717: Subsequent property declarations must have the same type.  Property 'cf' must be of type 'IncomingRequestCfProperties', but here has type 'IncomingCloudflareProperties'.

5   cf: IncomingCloudflareProperties;
    ~~

  node_modules/@cloudflare/workers-types/index.d.ts:313:3
    313   cf: IncomingRequestCfProperties;
          ~~
    'cf' was also declared here.

If `compose` is used with `prepare`, main handler never called

For example, this code does not working.

const API = new Router();

API.prepare = compose(
  async (req, res) => {
    // do nothing
    // return Promise<void>
  }
);

API.add("GET", "/hello", async (req, res) => {
  // not called...
});

This snippet from #29 does not work too.

API.prepare = compose(
  // Apply CORS globally
  CORS.preflight,
  // Validate Auth if "/admin/*" route
  async (req, res) => {
    if (req.pathname.startsWith('/admin/') {
      // assume this is Promise<Response | void>
      return Auth.validate(req, res);
    }
  }
);

It seems that call function inside of compose returns Response at last composed function causes this behavior.

Is this intended?

Consider eventual consisteny in TODO example

After several weeks working with Cloudflare workers, I just realized how the KV storage behaves from an application perspective. As mentioned in the documentation reads and write are eventually consistent.

Due to the eventually consistent nature of Workers KV, concurrent writes from different edge locations can end up up overwriting one another. It’s a common pattern to write data via Wrangler or the API but read the data from within a worker, avoiding this issue by issuing all writes from the same location.

Note that get may return stale values -- if a given key has recently been read in a given location, changes to the key made in other locations may take up to 60 seconds to be visible. See How KV works for more on this topic.

Ref: https://developers.cloudflare.com/workers/runtime-apis/kv#reading-key-value-pairs

This means we can't write based on a previous read state. In the TODO example, we will lose data when multiple users create TODO's in the time range of the synchronization between the edges.

  1. User A creates a new TODO item.
  2. User B creates a new TODO item.
  3. User B sync list.
  4. User A sync list.
  5. TODO item from User B is missing or vice versa.

I opened this issue to communicate that circumstance a bit deeper. Durable Objects will solve it but I'm surprised that they can only hold 32KiB 😕

worktop/storage

Or worktop/kvs or worktop/database
Depends if/how Durable Objects get worked in or if they should be their own separate module.

NOTE@SELF: Port this over from App2

Add subrouter functionality

Initially I wanted to disallow this entirely (avoid Express nesting nightmare), and while I still mostly lean in that direction, there are valid use cases where the DX is better to have a "subrouter" than to split routing/behavior logic across two locations. (Thanks @evanderkoogh for raising this!)

Suppose you wanted to require Authentication header validation for all /admin/* routes:

Before

AKA current

// src/index.js
import * as CORS from 'worktop/cors';
import * as Cache from 'worktop/cache';
import { Router, compose } from 'worktop';
import * as Projects from './routes/projects';
import * as Auth from './utils/auth';

const API = new Router;

API.prepare = compose(
  // Apply CORS globally
  CORS.preflight,
  // Validate Auth if "/admin/*" route
  async (req, res) => {
    if (req.pathname.startsWith('/admin/') {
      // assume this is Promise<Response | void>
      return Auth.validate(req, res);
    }
  }
);

// The actual administrative actions
// > Authentication & Authorization handled above
API.add('GET', '/admin/projects', Projects.list);
API.add('POST', '/admin/projects', Projects.create);

Cache.listen(API.run);

After

// src/routes/admin.js
import { Router } from 'worktop';
import * as Auth from '../utils/auth';
import * as Projects from './projects';

// NOTE: Exported!
export const Admin = new Router;

// All routes in this Router must 
// have valid Authentication header
Admin.prepare = Auth.validate;

API.add('GET', '/projects', Projects.list);
API.add('POST', '/projects', Projects.create);


// src/index.js
import { Router } from 'worktop';
import * as CORS from 'worktop/cors';
import * as Cache from 'worktop/cache';
import { Admin } from './routes/admin';

const API = new Router;

// Apply CORS globally
API.prepare = CORS.preflight;

// NEW – direct all "/admin/*" routes to Admin router
API.attach('/admin', Admin);

Cache.listen(API.run);

The important thing to note here is that the API.attach method (in the last snippet) is the new proposed method. I'm also considering "mount" as the method name... or "direct" lol. I'm purposefully avoiding .all() and .use() here, since they're loaded terms – more on that below.

Relatedly, this means that all /admin/* routes must be handled by the Admin router. In other words, doing something like this will fail/never hit the foo or the bar handlers.

// I WILL NEVER RUN
API.add('GET', '/admin/foo', foo);

// attach subrouter
API.attach('/admin', Admin);

// NEITHER WILL I
API.add('GET', '/admin/bar', bar);

This may seem counterintuitive for Node.js users (especially Express users) since they may be used to the stacking-router model. In worktop, every route points to a single handler – handler/middleware functions can be composed into a final handler, but it's still a single handler. This new method name needs to double-down on/imply this distinction. Calling this all() or use() would be cause confusion since, coming from Express land, those imply that you're attaching items that expect to work alongside / in conjunction with other handlers. This isn't the case here – it's a complete detour.

Open to feedback & suggestions!

invalid route onerror callback

API.add("POST", "/post", handler);

If the user tries a POST request on /invalid, API.onerror returns a null error. Is there a way to check that it's an invalid route? Bonus points if it doesn't trigger onerror at all and fails silently.

Illegal invocation when calling req.extend

When calling req.extend, it triggers a 500 error with the message Illegal invocation. I have tried using event.waitUntil directly and it seems to work. Following is the relevant code, please let me know if I should provide any other information.

import { Router, listen } from 'worktop'

const router = new Router()

const handleTest = async (event) => {
  event.waitUntil(fetch('https://cloudflare.com'))
  return new Response('ok')
}

router.add('GET', '/test2', async (req, res) => {
  req.extend(fetch('https://cloudflare.com'))
  res.send(200, 'ok')
})

addEventListener('fetch', (event) => {
  const { pathname } = new URL(event.request.url)
  if (pathname === '/test') event.respondWith(handleTest(event))
  else listen(router.run)
})

Include a `worktop build` command

I've been going back on forth on this for a while... Basically, it's a debate between:

  1. Include a worktop CLI that builds a Worker for you
  2. Offer a series of bundler plugins that you can import/attach to your existing toolkit

I suppose it's possible to do both, but the array of plugins would be second-class in that scenario, especially since it's (probably) unlikely that each integration could offer the same level of output refinement.


PROs

It'd be nice to have a ready-to-go build system for you, allowing (most) users to avoid the webpack that wrangler includes. This would solve low-hanging issues (eg #62, #73, #81). There's already very little reason to use webpack anyway, but really, this is the only unmatched benefit to offering a build-in CLI solution. In other words, even with a worktop/webpack plugin, there's still the "exports" and ESM resolving issue that I (as the plugin) can't fix without risking other resolutions breaking.

That said, just to reiterate, any esbuild/swc/Rollup configuration works out of the box. Webpack is purposefully counter-compliant here. 👎

Either as a worktop CLI or as a series of plugins, I'm going to want to do AST transformations. For long before worktop's first public release, I've had a PoC that compiles away the worktop Router in production. Even though the Router is already fast and lightweight, this removes all doubt and produces the most optimized form of the equivalent routing logic.

If I ship a CLI, it's easier to defend including an entire @swc/core installation. It also means that worktop could lean into other compile-time optimizations/rewrites. Going the plugins route, I'd have to sheepishly include all of @swc/core anyway, which is kinda like embedding a bundler within your existing bundler, or rewrite all the same AST operations using the target system's offerings –– i've explored this for another project... it's not fun.

CONs

  • Becomes an extra config file to learn/manage (can offer good defaults, but still)
  • Having a worktop build sorta implies the existence/inevitably of a worktop dev command
    Can always attach miniflare/equivalent, but the expectation will be there and creates far more scope.
  • Project scope grows significantly, especially trying to accomodate all existing user config
  • Worktop is actually really easy/simple to build now. It doesn't need any of the special sauce I'm talking about here.

Current Stance

Right now, I'm thinking that if I do this, it'd all take place under another package. That would mean that your package.json looks something like this:

{
  "scripts": {
    "build": "worktop build"
  },
  "dependencies": {
    "worktop": "latest"
  },
  "devDependencies": {
    "worktop-builder": "latest"
  }
}

This way, you only buy into all of the build-time optimizations if you decide to actually use/install worktop-builder (name tbd). If you don't install and use the toolkit, then you use worktop as you/we already do today.

Two quick polls to (anonymously) weigh in on the points raised here. Thank you~! 🙇

Plugins vs CLI




Single vs Separate Packages


worktop + cfw template

Hey Luke,

Many thanks for working on worktop and cfw 🙏🙏🙏.

Is there any chance for worktop + cfw template?

Currently, i fork from kv-todos for new projects.

Have a great weekend, btw!

worktop/base64

This is labeled as maybe because 2/3 of the exports listed as just aliases of built-in methods. However, this may still be useful because, personally, I rarely remember the atob-vs-btoa direction on the first try 😅

Exports:

  • encode (alias for btoa)
  • decode (alias for atob)
  • toURL – URL-safe Base64 value

Another possibility is that only toURL is added to worktop/utils as toBase64URL & then users are left to remember btoa-vs-atob on their own 😬

I think I personally prefer a base64 module, even if it's somewhat trivial. Thoughts?

Can't import the named export 'parse' from non EcmaScript module

Hi @lukeed. First of all, thank you for worktop! Using this feels like a dream compared to what I was putting together before when working with Cloudflare workers.

I was following along this Fauna tutorial and ran into the following issue when running wrangler dev (and wrangler publish). I noticed that this error only triggers for v0.7.0 and that using v.0.6.3 does not trigger the error and the project runs fine.

Do you know what the problem might be? I'm not using TypeScript and my node version is v15.2.1. This was a pretty minimal project as described in the tutorial but please let me know if you want me to create a repo that can recreate the error.

👀  ./node_modules/worktop/router/index.mjs 80:36-37
Can't import the named export 'parse' from non EcmaScript module (only default export is available)
    at HarmonyImportSpecifierDependency._getErrors (~/Library/Caches/.wrangler/wranglerjs-1.17.0/node_modules/webpack/lib/dependencies/HarmonyImportSpecifierDependency.js:90:6)
...

Edit: Just want to add that I have tried this in another project without faunadb and worktop is the only dependency.

request.headers returns empty json

Version 0.6.3

router.add('GET', '/', (request, response) => {
  console.log(request.headers)
  response.send(200, request.headers)
})

Returns

{}

Questions regarding caching api

Hi,
as stated in https://github.com/StarpTech/GraphCDN/blob/main/src/index.ts I wrap all my handler with the Cache API. Currently, there is only one GET route. Questions:

  • How can I check if the request was cached successfully by cloudflare?
  • When I use cache-control: public, max-age: 60, stale-if-error=60 directive and my worker throws or returns 500, the handler doesn't respond with a stale result. According to https://developers.cloudflare.com/workers/runtime-apis/cache#headers all directives are supported.

Add more `worktop/crypto` helpers

As of #11, there is now a worktop/crypto module that includes the following helpers:

  • digest(algo, message)
  • SHA1(message)
  • SHA256(message)
  • SHA384(message)
  • SHA512(message)

This ticket exists to collect suggestions for additional helpers that should be added to the module, if any.
So far, I think these would be good additions, if for no other reason than type safety:

declare function importkey(secret: string, algo: ALGO, scopes = ['sign', 'verify']): Promise<CryptoKey>;
declare function verify(secret: string, algo: ALGO, message: string): Promise<ArrayBuffer>;
declare function sign(secret: string, algo: ALGO, message: string): Promise<ArrayBuffer>;

Additionally, I have a PBKDF2 implementation that I can extract from existing application(s) and generalize it:

declare function PBKDF2(input: string, salt: string, iterations: number, keylen: number, digest: string): Promise<ArrayBuffer>;

What else should be here? 🙏


Lastly, WRT importkey, verify, and sign specifically – my applications' implementations only made use of a "raw" imported key:

// example
crypto.subtle.importKey('raw', ....);

Is/Was this application-specific? Or is this "the norm" for a Workers environment?

My hesitation is that these utilities will be too reliant on my importKey assumption/default and be incorrect for a larger audience.

Thanks!

Expose the raw `event.request`

https://github.com/lukeed/worktop/blob/master/src/request.ts#L4

$.request = request;

I intend on using this to do a passthrough handler:

export const logOnly: Handler = (request, _) => {
  log(await request.body.json())
  return await fetch(request.request);
};

As an aside, it also seems like you can't proxy websocket requests with worktop but you can with default workers. Exposing event.request would solve this as well.

https://community.cloudflare.com/t/websocket-pass-through-crashes-worker-script/78482/6

Can't import the named export 'parse' from non EcmaScript module (only default export is available)

Version 0.7.0 and 0.7.1

wrangler dev and wrangler publish

Result in:

./node_modules/worktop/router/index.mjs 80:36-37
Can't import the named export 'parse' from non EcmaScript module (only default export is available)
    at HarmonyImportSpecifierDependency._getErrors (C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\dependencies\HarmonyImportSpecifierDependency.js:88:6)
    at HarmonyImportSpecifierDependency.getErrors (C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\dependencies\HarmonyImportSpecifierDependency.js:68:16)
    at Compilation.reportDependencyErrorsAndWarnings (C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\Compilation.js:1463:22)
    at C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\Compilation.js:1258:10
    at AsyncSeriesHook.eval [as callAsync] (eval at create (C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\tapable\lib\HookCodeFactory.js:33:10), <anonymous>:24:1)
    at AsyncSeriesHook.lazyCompileHook (C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\tapable\lib\Hook.js:154:20)
    at Compilation.finish (C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\Compilation.js:1253:28)
    at C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\Compiler.js:672:17
    at eval (eval at create (C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\tapable\lib\HookCodeFactory.js:33:10), <anonymous>:11:1)
    at C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\Compilation.js:1185:12
    at C:\Users\billy\AppData\Local\.wrangler\wranglerjs-1.19.0\node_modules\webpack\lib\Compilation.js:1097:9
    at processTicksAndRejections (internal/process/task_queues.js:75:11)
 @ ./index.js
Error: webpack returned an error. You may be able to resolve this issue by running npm install.

Pass data between handlers

Is there an intuitive way to do this that I'm not seeing?

Some things I've tried:

  • Use request.params (pretty bad for obvious reasons)
  • Use response.setHeader and response.getHeader (not as bad but still pretty tedious)

Add `crypto.key` module

The current worktop/crypto module has keyload and keygen methods, but they're really not all that helpful. I end up dropping into crypto.subtle.importKey (and others) directly quite often, which is a good signal that something should be done here.

Additionally, the native TS definitions for importKey et all are pretty useless... With custom methods, I should be able to accurately offer strict overloads so that you can only have/define valid combinations. For example, for importKey has its own restrictions and generateKey has different input requirements based on format. Both of these can offer much better types - and possibly a tiny abstraction.

cors options request custom origin

API.prepare = compose(
  CORS.preflight({
    origin: '*',
    headers: ['Cache-Control', 'Content-Type'],
    methods: ['POST'],
    credentials: true,
  }),
  (request, response) => {
    CORS.headers(response, {
      origin: request.headers.get('Origin') ?? '*',
    });
  },
);

This reacts to the origin header on non-OPTIONS requests but it doesn't do it for OPTIONS requests. Any ideas?

[Bug] TypeError: Illegal invocation

Hey!

I'm running into:

Uncaught (in promise)
TypeError: Illegal invocation
    at worker.js:1:5254
    at f (worker.js:1:3637)
    at Object.run (worker.js:1:4646)
    at worker.js:1:4769
Uncaught (in response)
TypeError: Illegal invocation

When trying to use req.body.text() and req.body.json() in a cloudflare worker.
I've wrapped the invocation in a try/catch and the error is empty.

With Worktop

import { Router } from 'worktop'
const API = new Router()

API.add('POST', '/webhook', async function (req, res) {
    const body = await req.body.text()
    console.log(body)
})

addEventListener('fetch', API.listen)
// TypeError: Illegal invocation

Without Worktop

async function handleRequest (request) {
  const body = await request.text()
  console.log(body)
}

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})
// ok!

utils: Change `uid` alphabet

Right now, the uid export inside worktop/utils is using a hexadecimal alphabet (abcdef1234567890), which it shared with the uuid export.

I think it should move to a full alphanumeric dictionary instead: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_

Of course, this allows for more combinations with shorter output lengths. For example, a to produce a 6-char string:

// hexadecimal (current)
6 ** 16; // 2.8211099e+12

// alphanumeric (proposed)
6 ** 64; // 6.3340287e+49

Creating a 1280-length character string using the current hexadecimal format has 5.1922969e+49 combinations, which is still fewer than a 6-length alphanumeric (full) alphabet.

Because the dictionary/options are changing, this would be a breaking change.

Rework Handler/Request/Response Signatures

Just brainstorming; nothing may actually come out of this!

I've been looking at the ServerRequest and ServerResponse interfaces a lot lately & wondering if they should even be here 🤔

Background

First, an overview of the PROs/CONs of everything, which lays out the reasoning for their existence:

ServerRequest

PROs

  • the ServerRequest<P> TS interface makes for really nice DX as it can give confidence wrt req.params contents
  • the req.body() "smart helper" is nice for auto-detecting & auto-parsing the body
  • ...nice to still have direct access to the req.body.json() (and other) methods, if needed
  • it's nice that new URL is done once and its values are stored on the request directly, so don't have to repeat

CONs

  • See #52
    • Only specific properties are forwarded from Request to ServerRequest
    • Impossible to do req.clone as it no longer exists
    • Cannot support new Request(req.url, req) out of the box (related #52)
  • It's invented, which means it requires setup/buy-in in order to use some (but not all) utilities Worktop offers
    • ws.connect doesn't have a preference
    • ws.listen returns a handler than needs to be given a ServerRequest because of the req.params usage within it
  • A ServerRequest must be created from a FetchEvent in order for req.extend to exist
    • this is not ideal because Module Workers don't have FetchEvents

Visually

Compared to Request, these are the property differences:

  url: string;
++ path: string;
  method: Method;
++ origin: string;
++ hostname: string;
++ search: string;
++ query: URLSearchParams;
++ extend: FetchEvent['waitUntil'];
  cf: IncomingCloudflareProperties;
  headers: Headers;
++ params: P;
-- json(): Promise<any>;
-- formData(): Promise<FormData>;
-- arrayBuffer(): Promise<ArrayBuffer>;
-- blob(): Promise<Blob>;
-- text(): Promise<string>;
++ body: {
++	<T>(): Promise<T|void>;
++	json<T=any>(): Promise<T>;
++	arrayBuffer(): Promise<ArrayBuffer>;
++	formData(): Promise<FormData>;
++	text(): Promise<string>;
++	blob(): Promise<Blob>;
++ };
-- cache: RequestCache;
-- credentials: RequestCredentials;
-- destination: RequestDestination;
-- integrity: string;
-- keepalive: boolean;
-- mode: RequestMode;
-- redirect: RequestRedirect;
-- referrer: string;
-- referrerPolicy: ReferrerPolicy'
-- signal: AbortSignal;
-- clone(): Request;

ServerResponse

The main purpose of ServerResponse was to surface a Node.js-like API for composing responses.

PROs

  • Has res.end, res.setHeader, res.writeHead and a bunch of others
  • Still able to do new Response(res.body, res) for ServerResponse -> Response transform
  • Contents remain mutable for request lifecycle (desired) until res.end or res.send is called
  • Only relies on req.method for construction – used for HEAD checks
  • Provides res.send for automatic Content-* headers and res.body serialization

CONs

  • Custom, requires manual construction and/or adjustment for type match
  • Does not satisfy native Response type requirements
    • AKA, can't pass a ServerResponse to an external library/helper if it wants a Response value
  • Even though ServerResponse is tiny, it's still extra code

Handler

The Handler right now is strictly tied to the ServerRequest, ServerResponse pair. It makes sense for this to always have some worktop-specific signature to it, but the question boils down to whether or not ServerRequest and ServerResponse are the right base units.

The signature now is this:

type Handler<P> = (req: ServerRequest<P>, res: ServerResponse) => Promisable<Response|void>;

...which satisfies all "middleware" and "final/route handler" requirements. Worktop loops through all route handlers until either:

  1. a Response or Promise<Response> is returned directly
  2. the internal res.send or res.end have been called, which marks the ServerResponse as finished, and a Response is created from the ServerResponse contents

PROs

  • Easily composable
  • Satisfies middleware & final handler use cases
  • Predictable & easily understood
  • Allows mix/match of Promises
  • No need for next()

CONs

  • Requires ServerRequest and ServerResponse to be setup first

With the background out of the way, I have a few ideas of how these could be simplified and/or reworked to be compatible with libraries outside of worktop.

Important: All of these suggestions are breaking changes, which is not taken lightly.
If any of these are to happen, Worktop would have a strong division between old-vs-new through versioning – supporting the existing API and {new stuff} would not be considered.

Request Changes

1. Raw Request and add $ object for all customizations

This drops ServerRequest and instead uses Request directly, adding a $ property that has an object with all worktop extras. This would be like how Cloudflare adds the cf key with its own metadata.

interface RequestExtras<P extends Params> {
  params: P;
  path: string;
  origin: string;
  hostname: string;
  search: string;
  query: URLSearchParams;
  body<T>(): Promise<T | void>;
  extend: FetchEvent['waitUntil'];
}

interface Request<P extends Params = Params> {
  $: RequestExtras<P>;
}

TypeScript Playground

Breaks:

  • req.params -> req.$.params
  • req.path -> req.$.path
  • req.* -> req.$.*
  • req.body() -> req.$.body()
  • req.body.json() -> req.json() (rely on native)
  • req.body.*() -> req.*() (rely on native)
  • req.extend() -> req.$.extend()

Additionally, I see two potential issues with this:

  • It converts the Request interface to a generic, which means code can throw/cause TS errors when used outside of worktop. In other words, if someone tries to do Request<MyParams> outside of worktop, then that's gunna throw an error because Request, natively, is not a generic. Not having the generic means that worktop loses its params insights.
  • Worktop can ensure req.$ always exists, but if a user writes a middleware/handler to be used outside of worktop, then something like req.$.path will throw a cannot access "path" of undefined error

2. Raw Request and use context object for all customizations

This is the exact same thing as above, but it uses the context key instead of $ for the object.

TypeScript Playground

3. Add all customizations directly to a Request object

Uses the incoming Request as is, but adds all the worktop customizations to it directly:

interface Request<P extends Params = Params> {
  params: P;
  path: string;
  origin: string;
  hostname: string;
  search: string;
  query: URLSearchParams;
  extend: FetchEvent['waitUntil'];
}

TypeScript Playground

Breaks:

  • req.body() -> removed – use external utility
  • req.body.json() -> removed – use req.json()
  • req.body.*() -> removed – use req.*()

Additionally, this shares the same potential gotchas as Options 1 & 2

  • Worktop assumes/requires Request to be a generic; might cause issues externally
  • Worktop provides the req.params object; any user middleware running outside of worktop might include req.params.foo and req.params is not defined

Request Changes: Poll





Response Changes

There's not much that can really change about ServerResponse since it's all custom/worktop's to begin with... Really, there's only been on thing on my mind:

Move res.send to external utility

If res.send were exported as a top-level send utility (either from worktop/response or from worktop/utils), then people could use it externally.

In order to accomodate this change, send would have to return a Response directly. Right now it mutates the ServerResponse to signal the worktop.Router that the Handler loop is complete. All of the serialization & statusCode checks would happen within the send utility itself. However, it would be up to the Router to perform the HEAD check. This would keep its API more or less comparable to @polka/send.

Note: It's entirely possible to keep res.send and add a send export.

Response Changes: Poll




Handler Changes

As mentioned before, the Handler itself depends on decisions made to (Server)Request and (Server)Response. So any suggestions here will be made in addition to those, as this section is focusing on the Handler function signature itself.

Important: For brevity, I'll use Promise<Response> as a return type to refer to Promisable<Response | void>

1. Absolutely no changes

Keep the signature the same and use the exactly same ServerRequest and ServerResponse types:

type Handler<P> = (req: ServerRequest<P>, res: ServerResponse): Promise<Response>;

2. Keep the signature, but use Request type

Maintain the (req, res) parameters, but replace ServerRequest with one of the suggestions above.

type Handler<P> = (req: Request<P>, res: ServerResponse): Promise<Response>;

3. Use Request+ and a Context object

Note: Using Request+ to denote an overloaded/modified Request type; see above.

Changes the signature so that a Request is always used, leaving it up to Context to store additional/extra information. Your Handlers will be passing around the same Context object, which allows middleware/handlers (including Router.prepare) to mutate the context as needed.

For this Option 3 entry, the Context object looks like this:

type Context = {
  response: ServerResponse;
  waitUntil: FetchEvent['waitUntil'];
}

type Handler<
  P extends Params = Params,
  C extends Context = Context,
> = (req: Request<P>, context: C) => Promise<Response>;
// ^ this means you can inject your own `Context` types

4. Use pure Request and a Context object

Similar to Option 3, but moves all would-be ServerRequest extras into the Context object. This means that the Request is pure and has nothing added onto it (other than Cloudflare's cf property)

type Context<P extends Params = Params> = {
  // Request extras
  params: P;
  path: string;
  origin: string;
  hostname: string;
  search: string;
  query: URLSearchParams;
  // the FetchEvent information
  extend: FetchEvent['waitUntil']; // name TBD
  waitUntil: FetchEvent['waitUntil']; // name TBD
  passThroughOnException: FetchEvent['passThroughOnException']; // name TBD
  // the ServerResponse 
  response: ServerResponse;
}

type Handler<
  P extends Params = Params,
  C extends Context = Context,
> = (req: Request, context: C<P>) => Promise<Response>;
// ^ this means you can inject your own `Context` types

5. Only receive a Context parameter

Change the Handler signature so that it's just receiving a single object with everything in it. It may look something like this:

interface Context<P extends Params = Params> {
  // raw request
  request: Request;
  // the ServerResponse 
  response: ServerResponse;

  // request extras
  params: P;
  path: string;
  origin: string;
  hostname: string;
  search: string;
  query: URLSearchParams;

  // the FetchEvent information
  extend: FetchEvent['waitUntil']; // name TBD
  waitUntil: FetchEvent['waitUntil']; // name TBD
  passThroughOnException: FetchEvent['passThroughOnException']; // name TBD
}

type Handler<
  P extends Params = Params,
  C extends Context = Context,
> = (context: C<P>) => Promise<Response>;

Handler Changes: Poll





Now, if you voted for something that involved a Context object, should worktop auto-create a ServerResponse for you?

All Context-based answers assume that ServerRequest is gone, shifting its properties either to the req directly or to the Context object. In this world, Worktop can continue providing a context.response (name TBD) for you, or you can create it yourself as part of your Router.prepare hook.

The purists among you may have despised the wannabe-Nodejs all this time, so this would be the opportunity to explicitly opt into ServerResponse only when needed ... something like this:

import { Router } from 'worktop';
import { ServerResponse } from 'worktop/response';
import type { Context } from 'worktop';

interface MyContext extends Context {
  response: ServerResponse;
}

const API = new Router<MyContext>();

API.prepare = function (req, context) {
  // assumes no changes to ServerResponse API
  context.response = new ServerResponse(req.method);
}
// or
API.prepare = function (context) {
  // assumes no changes to ServerResponse API
  context.response = new ServerResponse(context.request.method);
}



Thank you!

I know this a lot (too much) to read and sift through. If you managed to go through it – or even some of it – thank you so much. I really really appreciate the feedback.

No query string was present

Fail with No query string was present in the browser but works with CURL

https://gitlab.fastgraph.de?query={__typename}&extensions={%22persistedQuery%22:{%22version%22:1,%22sha256Hash%22:%22ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38%22}}

CURL

curl --request GET \
  --url 'https://gitlab.fastgraph.de?query=%7B__typename%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38%22%7D%7D' \
  --header 'Accept-Encoding: br' \
  --header 'Content-Type: application/json' \
  --data '{"query":""}'

Source: https://github.com/StarpTech/FastGraph/blob/main/src/routes/apq.ts

worktop/utils

Need to filter through my existing applications and extract the things that don't look app-specific.

This will be a (mostly) random hodgepodge of utilities. If there's enough methods that can fall under a loosely-defined umbrella, then I'll move those to their own submodule. An example of this is that I think I may have enough to justify a worktop/crypto submodule... we'll see.

Decorators & Plugins

Summary

I see that you have three tickets for modules you intend to provide within Worktop:

I'm a fan of the Worktop project already and am wondering if you've considered the Fastify Decorators and Plugins approach to those kinds of modules:

Rather than implementing a list of modules, create structured entry-points for extending Worktop. Then if a module like CORS is required for a particular use-case, you bring in and register or decorate that module. (Like fastify-cors)

I have a rough fork of Worktop where I've implemented a simple decorator method on the router (see the example below). If you're accepting proposals and contributions, I'm happy to clean it up into a PR for your consideration!

Great work!

Example

import { Router } from 'worktop'
import { listen } from 'worktop/cache'

const API = new Router()

// Decorate an object for this API instance
API.decorate('processObject', process)

// Decorate a function for this API instance
API.decorate('setSeveralHeaders', function (res, [header, value] = []) {
	res.setHeader('foo','bar')
	res.setHeader('biz','baz')
	res.setHeader('wing','bird')
	if (header && value) {
		res.setHeader(header, value)
	}
})

API.add('GET', '/greet/:name', (req, res) => {
  // Retrieve the decorated object
  const { title } = API.processObject
  // Invoke the decorated function
  API.setSeveralHeaders(res, ['process-type', title])

  res.end(`Hello, ${req.params.name}!`)
})

listen(API.run)

Error handling

Being able to modify the onerror function of the Router class would work well.

// Adapted from https://developers.cloudflare.com/workers/examples/debugging-logs
API.onerror = (request, response, status, error) => {
	  request.extend(postLog(error.toString()))
	  const stack = JSON.stringify(error.stack) || error
	  const res = new Response(stack, response)
	  res.headers.set("X-Debug-stack", stack)
	  res.headers.set("X-Debug-err", error)
	  return res;
}

Add documentation website

The domain I had in mind got sniped 😢 which is actually the main reason a docs sites doesn't exist yet, haha

Right now, documentation mostly lives in/as the TypeScript definitions and the PR description(s) for new features. This is somewhat permissible for 0.x days, but will need something more formal for 1.0 and beyond.

WebSocket support

Hi. It would be cool if worktop can support WebSocket in the future. It's especially powerful when paired with Durable Object, as explained here.

I have been using https://github.com/uNetworking/uWebSockets.js. and quite like their API as I can define both http and ws routes.

app
  .ws(`/room/:id`, wsRouteConfig)
  .post('/project/create', projectCreateRoute)
  .any(`/*`, (res, _req) => {
    res.writeStatus(`404 Not Found`).end()
  })
  .listen(3001, () => {})

I'm considering switching to Cloudflare since I won't have to think about scaling infrastructure, which is enticing.

How to use Toucan logger

Hey there,

first thanks for the great library!

We're using https://github.com/robertcepa/toucan-js to log to sentry. Sadly we need the FetchEvent or at least the request + waitUntil to use it. Did you plan on expose these or add a Logger plugin?
Also could it be instantiated as a composed Handler? In a vanilla worker you would instantiate it before you go for a route to keep the code dry.

Thanks

Alex

Provide commonjs support to make your module easy to consume

Hi, from a consumer perspective the module isn't ready for typescript. Typescript has no import support for .mjs. This results in an error. The workaround described in #27 has no support for sourcemaps and therefore debugger is not supported. It also adds additional overhead to the setup. This isn't absolutely necessary.

I tried conditional exports and it works flawlessly with minimal overhead https://github.com/lukeed/worktop/compare/master...StarpTech:commonjs_support_conditional?expand=1. It would be very welcome to support it until a better solution can be provided.

hostname routing

The idea is to allow the same worker to service multiple domains ("zones"). Through Worker Routes, there's no reason why you can't/shouldn't be able to do this in a real application.

And with SSL for SaaS, this may be way more common.

  • add hostname to request data
  • add App.host('example.com').add('GET', '/foo', handler) API

Incorrect typing in `worktop/ws`

This code:

import type {Handler} from 'worktop';
import {connect} from 'worktop/ws';

export const handler: Handler = async (request) => {
  const error = connect(request);
  if (error) return error;
};

produces the following Eslint errors:

  ✖    5:17  Unsafe call of an any typed value.                                   @typescript-eslint/no-unsafe-call
  ✖    6:14  Unsafe return of an any typed value.                                 @typescript-eslint/no-unsafe-return

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.