=> {
44 | const iterator = await example(['foo', 'bar', 'baz']);
45 | for await (const value of iterator) {
46 | console.log('Received: ', value);
47 | }
48 | })().catch(() => {
49 | //
50 | });
51 | });
52 |
53 | return (
54 | <>
55 |
56 |
57 | Loading}>
58 | {data()?.data}
59 |
60 |
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/packages/vite/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.9.0",
3 | "type": "module",
4 | "types": "./dist/types/index.d.ts",
5 | "main": "./dist/cjs/production/index.cjs",
6 | "module": "./dist/esm/production/index.mjs",
7 | "exports": {
8 | ".": {
9 | "development": {
10 | "require": "./dist/cjs/development/index.cjs",
11 | "import": "./dist/esm/development/index.mjs"
12 | },
13 | "require": "./dist/cjs/production/index.cjs",
14 | "import": "./dist/esm/production/index.mjs",
15 | "types": "./dist/types/index.d.ts"
16 | }
17 | },
18 | "files": ["dist", "src"],
19 | "engines": {
20 | "node": ">=10"
21 | },
22 | "license": "MIT",
23 | "keywords": ["pridepack", "babel"],
24 | "name": "vite-plugin-thaler",
25 | "devDependencies": {
26 | "@types/node": "^20.11.3",
27 | "pridepack": "2.6.0",
28 | "tslib": "^2.6.2",
29 | "typescript": "^5.3.3",
30 | "vite": "^5.0.11"
31 | },
32 | "dependencies": {
33 | "unplugin-thaler": "0.9.0"
34 | },
35 | "peerDependencies": {
36 | "vite": "^3 || ^4 || ^5"
37 | },
38 | "scripts": {
39 | "prepublish": "pridepack clean && pridepack build",
40 | "build": "pridepack build",
41 | "type-check": "pridepack check",
42 | "clean": "pridepack clean"
43 | },
44 | "description": "Isomorphic server-side functions",
45 | "repository": {
46 | "url": "https://github.com/lxsmnsyc/thaler.git",
47 | "type": "git"
48 | },
49 | "homepage": "https://github.com/lxsmnsyc/thaler/tree/main/packages/vite",
50 | "bugs": {
51 | "url": "https://github.com/lxsmnsyc/thaler/issues"
52 | },
53 | "publishConfig": {
54 | "access": "public"
55 | },
56 | "author": "Alexis Munsayac",
57 | "private": false,
58 | "typesVersions": {
59 | "*": {}
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/packages/unplugin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.9.0",
3 | "type": "module",
4 | "types": "./dist/types/index.d.ts",
5 | "main": "./dist/cjs/production/index.cjs",
6 | "module": "./dist/esm/production/index.mjs",
7 | "exports": {
8 | ".": {
9 | "development": {
10 | "require": "./dist/cjs/development/index.cjs",
11 | "import": "./dist/esm/development/index.mjs"
12 | },
13 | "require": "./dist/cjs/production/index.cjs",
14 | "import": "./dist/esm/production/index.mjs",
15 | "types": "./dist/types/index.d.ts"
16 | }
17 | },
18 | "files": ["dist", "src"],
19 | "engines": {
20 | "node": ">=10"
21 | },
22 | "license": "MIT",
23 | "keywords": ["pridepack", "babel"],
24 | "name": "unplugin-thaler",
25 | "devDependencies": {
26 | "@types/node": "^20.11.3",
27 | "pridepack": "2.6.0",
28 | "thaler": "0.9.0",
29 | "tslib": "^2.6.2",
30 | "typescript": "^5.3.3"
31 | },
32 | "peerDependencies": {
33 | "thaler": ">=0.6.0",
34 | "vite": "^3 || ^4 || ^5"
35 | },
36 | "peerDependenciesMeta": {
37 | "vite": {
38 | "optional": true
39 | }
40 | },
41 | "dependencies": {
42 | "@rollup/pluginutils": "^5.1.0",
43 | "unplugin": "^1.6.0"
44 | },
45 | "scripts": {
46 | "prepublish": "pridepack clean && pridepack build",
47 | "build": "pridepack build",
48 | "type-check": "pridepack check",
49 | "clean": "pridepack clean"
50 | },
51 | "description": "Isomorphic server-side functions",
52 | "repository": {
53 | "url": "https://github.com/lxsmnsyc/thaler.git",
54 | "type": "git"
55 | },
56 | "homepage": "https://github.com/lxsmnsyc/thaler/tree/main/packages/unplugin",
57 | "bugs": {
58 | "url": "https://github.com/lxsmnsyc/thaler/issues"
59 | },
60 | "publishConfig": {
61 | "access": "public"
62 | },
63 | "author": "Alexis Munsayac",
64 | "private": false,
65 | "typesVersions": {
66 | "*": {}
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/packages/thaler/src/index.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ThalerPostFunction,
3 | ThalerPostHandler,
4 | ThalerPostParam,
5 | ThalerFunction,
6 | ThalerFnHandler,
7 | ThalerGetFunction,
8 | ThalerGetHandler,
9 | ThalerGetParam,
10 | ThalerPureFunction,
11 | ThalerPureHandler,
12 | ThalerServerFunction,
13 | ThalerServerHandler,
14 | ThalerLoaderHandler,
15 | ThalerLoaderFunction,
16 | ThalerActionHandler,
17 | ThalerActionFunction,
18 | } from '../shared/types';
19 |
20 | export * from '../shared/types';
21 |
22 | export function server$(_handler: ThalerServerHandler): ThalerServerFunction {
23 | throw new Error('server$ cannot be called during runtime.');
24 | }
25 |
26 | export function post$(
27 | _handler: ThalerPostHandler
,
28 | ): ThalerPostFunction
{
29 | throw new Error('post$ cannot be called during runtime.');
30 | }
31 |
32 | export function get$
(
33 | _handler: ThalerGetHandler
,
34 | ): ThalerGetFunction
{
35 | throw new Error('get$ cannot be called during runtime.');
36 | }
37 |
38 | export function fn$(
39 | _handler: ThalerFnHandler,
40 | ): ThalerFunction {
41 | throw new Error('fn$ cannot be called during runtime.');
42 | }
43 |
44 | export function pure$(
45 | _handler: ThalerPureHandler,
46 | ): ThalerPureFunction {
47 | throw new Error('pure$ cannot be called during runtime.');
48 | }
49 |
50 | export function loader$(
51 | _handler: ThalerLoaderHandler
,
52 | ): ThalerLoaderFunction
{
53 | throw new Error('fn$ cannot be called during runtime.');
54 | }
55 |
56 | export function action$
(
57 | _handler: ThalerActionHandler
,
58 | ): ThalerActionFunction
{
59 | throw new Error('pure$ cannot be called during runtime.');
60 | }
61 |
62 | export function ref$(_value: T): T {
63 | throw new Error('ref$ cannot be called during runtime.');
64 | }
65 |
66 | export {
67 | fromFormData,
68 | fromURLSearchParams,
69 | } from '../shared/utils';
70 |
--------------------------------------------------------------------------------
/packages/thaler/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.production
74 | .env.development
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 |
79 | # Next.js build output
80 | .next
81 |
82 | # Nuxt.js build / generate output
83 | .nuxt
84 | dist
85 |
86 | # Gatsby files
87 | .cache/
88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
89 | # https://nextjs.org/blog/next-9-1#public-directory-support
90 | # public
91 |
92 | # vuepress build output
93 | .vuepress/dist
94 |
95 | # Serverless directories
96 | .serverless/
97 |
98 | # FuseBox cache
99 | .fusebox/
100 |
101 | # DynamoDB Local files
102 | .dynamodb/
103 |
104 | # TernJS port file
105 | .tern-port
106 |
107 | .npmrc
108 |
--------------------------------------------------------------------------------
/packages/unplugin/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.production
74 | .env.development
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 |
79 | # Next.js build output
80 | .next
81 |
82 | # Nuxt.js build / generate output
83 | .nuxt
84 | dist
85 |
86 | # Gatsby files
87 | .cache/
88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
89 | # https://nextjs.org/blog/next-9-1#public-directory-support
90 | # public
91 |
92 | # vuepress build output
93 | .vuepress/dist
94 |
95 | # Serverless directories
96 | .serverless/
97 |
98 | # FuseBox cache
99 | .fusebox/
100 |
101 | # DynamoDB Local files
102 | .dynamodb/
103 |
104 | # TernJS port file
105 | .tern-port
106 |
107 | .npmrc
108 |
--------------------------------------------------------------------------------
/packages/vite/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.production
74 | .env.development
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 |
79 | # Next.js build output
80 | .next
81 |
82 | # Nuxt.js build / generate output
83 | .nuxt
84 | dist
85 |
86 | # Gatsby files
87 | .cache/
88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
89 | # https://nextjs.org/blog/next-9-1#public-directory-support
90 | # public
91 |
92 | # vuepress build output
93 | .vuepress/dist
94 |
95 | # Serverless directories
96 | .serverless/
97 |
98 | # FuseBox cache
99 | .fusebox/
100 |
101 | # DynamoDB Local files
102 | .dynamodb/
103 |
104 | # TernJS port file
105 | .tern-port
106 |
107 | .npmrc
108 |
--------------------------------------------------------------------------------
/packages/thaler/compiler/get-foreign-bindings.ts:
--------------------------------------------------------------------------------
1 | import type * as babel from '@babel/core';
2 | import * as t from '@babel/types';
3 |
4 | function isForeignBinding(
5 | source: babel.NodePath,
6 | current: babel.NodePath,
7 | name: string,
8 | ): boolean {
9 | if (current.scope.hasGlobal(name)) {
10 | return false;
11 | }
12 | if (source === current) {
13 | return true;
14 | }
15 | if (current.scope.hasOwnBinding(name)) {
16 | return false;
17 | }
18 | if (current.parentPath) {
19 | return isForeignBinding(source, current.parentPath, name);
20 | }
21 | return true;
22 | }
23 |
24 | function isInTypescript(path: babel.NodePath): boolean {
25 | let parent = path.parentPath;
26 | while (parent) {
27 | if (t.isTypeScript(parent.node) && !t.isExpression(parent.node)) {
28 | return true;
29 | }
30 | parent = parent.parentPath;
31 | }
32 | return false;
33 | }
34 |
35 | export default function getForeignBindings(
36 | path: babel.NodePath,
37 | ): t.Identifier[] {
38 | const identifiers = new Set();
39 | path.traverse({
40 | ReferencedIdentifier(p) {
41 | // Check identifiers that aren't in a TS expression
42 | if (!isInTypescript(p) && isForeignBinding(path, p, p.node.name)) {
43 | identifiers.add(p.node.name);
44 | }
45 | },
46 | });
47 |
48 | const result: t.Identifier[] = [];
49 | for (const identifier of identifiers) {
50 | const binding = path.scope.getBinding(identifier);
51 |
52 | if (binding) {
53 | switch (binding.kind) {
54 | case 'const':
55 | case 'let':
56 | case 'var':
57 | case 'param':
58 | case 'local':
59 | case 'hoisted': {
60 | let blockParent = binding.path.scope.getBlockParent();
61 | const programParent = binding.path.scope.getProgramParent();
62 |
63 | if (blockParent.path === binding.path) {
64 | blockParent = blockParent.parent;
65 | }
66 |
67 | // We don't need top-level declarations
68 | if (blockParent !== programParent) {
69 | result.push(t.identifier(identifier));
70 | }
71 | break;
72 | }
73 | default:
74 | break;
75 | }
76 | }
77 | }
78 | return result;
79 | }
80 |
--------------------------------------------------------------------------------
/examples/sveltekit/src/routes/styles.css:
--------------------------------------------------------------------------------
1 | @import '@fontsource/fira-mono';
2 |
3 | :root {
4 | --font-body: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
5 | Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
6 | --font-mono: 'Fira Mono', monospace;
7 | --color-bg-0: rgb(202, 216, 228);
8 | --color-bg-1: hsl(209, 36%, 86%);
9 | --color-bg-2: hsl(224, 44%, 95%);
10 | --color-theme-1: #ff3e00;
11 | --color-theme-2: #4075a6;
12 | --color-text: rgba(0, 0, 0, 0.7);
13 | --column-width: 42rem;
14 | --column-margin-top: 4rem;
15 | font-family: var(--font-body);
16 | color: var(--color-text);
17 | }
18 |
19 | body {
20 | min-height: 100vh;
21 | margin: 0;
22 | background-attachment: fixed;
23 | background-color: var(--color-bg-1);
24 | background-size: 100vw 100vh;
25 | background-image: radial-gradient(
26 | 50% 50% at 50% 50%,
27 | rgba(255, 255, 255, 0.75) 0%,
28 | rgba(255, 255, 255, 0) 100%
29 | ),
30 | linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 15%, var(--color-bg-2) 50%);
31 | }
32 |
33 | h1,
34 | h2,
35 | p {
36 | font-weight: 400;
37 | }
38 |
39 | p {
40 | line-height: 1.5;
41 | }
42 |
43 | a {
44 | color: var(--color-theme-1);
45 | text-decoration: none;
46 | }
47 |
48 | a:hover {
49 | text-decoration: underline;
50 | }
51 |
52 | h1 {
53 | font-size: 2rem;
54 | text-align: center;
55 | }
56 |
57 | h2 {
58 | font-size: 1rem;
59 | }
60 |
61 | pre {
62 | font-size: 16px;
63 | font-family: var(--font-mono);
64 | background-color: rgba(255, 255, 255, 0.45);
65 | border-radius: 3px;
66 | box-shadow: 2px 2px 6px rgb(255 255 255 / 25%);
67 | padding: 0.5em;
68 | overflow-x: auto;
69 | color: var(--color-text);
70 | }
71 |
72 | .text-column {
73 | display: flex;
74 | max-width: 48rem;
75 | flex: 0.6;
76 | flex-direction: column;
77 | justify-content: center;
78 | margin: 0 auto;
79 | }
80 |
81 | input,
82 | button {
83 | font-size: inherit;
84 | font-family: inherit;
85 | }
86 |
87 | button:focus:not(:focus-visible) {
88 | outline: none;
89 | }
90 |
91 | @media (min-width: 720px) {
92 | h1 {
93 | font-size: 2.4rem;
94 | }
95 | }
96 |
97 | .visually-hidden {
98 | border: 0;
99 | clip: rect(0 0 0 0);
100 | height: auto;
101 | margin: 0;
102 | overflow: hidden;
103 | padding: 0;
104 | position: absolute;
105 | width: 1px;
106 | white-space: nowrap;
107 | }
108 |
--------------------------------------------------------------------------------
/packages/thaler/compiler/imports.ts:
--------------------------------------------------------------------------------
1 | export interface NamedImportDefinition {
2 | name: string;
3 | source: string;
4 | kind: 'named';
5 | }
6 |
7 | export interface DefaultImportDefinition {
8 | source: string;
9 | kind: 'default';
10 | }
11 |
12 | export type ImportDefinition = NamedImportDefinition | DefaultImportDefinition;
13 |
14 | export interface APIRegistration {
15 | name: string;
16 | scoping: boolean;
17 | target: ImportDefinition;
18 | client: ImportDefinition;
19 | server: ImportDefinition;
20 | }
21 |
22 | export const API: APIRegistration[] = [
23 | {
24 | name: 'server$',
25 | scoping: false,
26 | target: {
27 | name: 'server$',
28 | source: 'thaler',
29 | kind: 'named',
30 | },
31 | client: {
32 | name: '$$server',
33 | source: 'thaler/client',
34 | kind: 'named',
35 | },
36 | server: {
37 | name: '$$server',
38 | source: 'thaler/server',
39 | kind: 'named',
40 | },
41 | },
42 | {
43 | name: 'post$',
44 | scoping: false,
45 | target: {
46 | name: 'post$',
47 | source: 'thaler',
48 | kind: 'named',
49 | },
50 | client: {
51 | name: '$$post',
52 | source: 'thaler/client',
53 | kind: 'named',
54 | },
55 | server: {
56 | name: '$$post',
57 | source: 'thaler/server',
58 | kind: 'named',
59 | },
60 | },
61 | {
62 | name: 'get$',
63 | scoping: false,
64 | target: {
65 | name: 'get$',
66 | source: 'thaler',
67 | kind: 'named',
68 | },
69 | client: {
70 | name: '$$get',
71 | source: 'thaler/client',
72 | kind: 'named',
73 | },
74 | server: {
75 | name: '$$get',
76 | source: 'thaler/server',
77 | kind: 'named',
78 | },
79 | },
80 | {
81 | name: 'fn$',
82 | scoping: true,
83 | target: {
84 | name: 'fn$',
85 | source: 'thaler',
86 | kind: 'named',
87 | },
88 | client: {
89 | name: '$$fn',
90 | source: 'thaler/client',
91 | kind: 'named',
92 | },
93 | server: {
94 | name: '$$fn',
95 | source: 'thaler/server',
96 | kind: 'named',
97 | },
98 | },
99 | {
100 | name: 'pure$',
101 | scoping: false,
102 | target: {
103 | name: 'pure$',
104 | source: 'thaler',
105 | kind: 'named',
106 | },
107 | client: {
108 | name: '$$pure',
109 | source: 'thaler/client',
110 | kind: 'named',
111 | },
112 | server: {
113 | name: '$$pure',
114 | source: 'thaler/server',
115 | kind: 'named',
116 | },
117 | },
118 | {
119 | name: 'loader$',
120 | scoping: false,
121 | target: {
122 | name: 'loader$',
123 | source: 'thaler',
124 | kind: 'named',
125 | },
126 | client: {
127 | name: '$$loader',
128 | source: 'thaler/client',
129 | kind: 'named',
130 | },
131 | server: {
132 | name: '$$loader',
133 | source: 'thaler/server',
134 | kind: 'named',
135 | },
136 | },
137 | {
138 | name: 'action$',
139 | scoping: false,
140 | target: {
141 | name: 'action$',
142 | source: 'thaler',
143 | kind: 'named',
144 | },
145 | client: {
146 | name: '$$action',
147 | source: 'thaler/client',
148 | kind: 'named',
149 | },
150 | server: {
151 | name: '$$action',
152 | source: 'thaler/server',
153 | kind: 'named',
154 | },
155 | },
156 | ];
157 |
--------------------------------------------------------------------------------
/packages/thaler/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "thaler",
3 | "version": "0.9.0",
4 | "type": "module",
5 | "files": ["dist", "src"],
6 | "engines": {
7 | "node": ">=10"
8 | },
9 | "license": "MIT",
10 | "keywords": ["pridepack"],
11 | "devDependencies": {
12 | "@types/babel__core": "^7.20.5",
13 | "@types/babel__helper-module-imports": "^7.18.3",
14 | "@types/babel__traverse": "^7.20.5",
15 | "@types/node": "^20.11.3",
16 | "pridepack": "2.6.0",
17 | "tslib": "^2.6.2",
18 | "typescript": "^5.3.3",
19 | "vitest": "^1.2.0"
20 | },
21 | "dependencies": {
22 | "@babel/core": "^7.23.7",
23 | "@babel/helper-module-imports": "^7.22.15",
24 | "@babel/traverse": "^7.23.7",
25 | "@babel/types": "^7.23.6",
26 | "seroval": "^1.0.4",
27 | "seroval-plugins": "^1.0.4"
28 | },
29 | "scripts": {
30 | "prepublishOnly": "pridepack clean && pridepack build",
31 | "build": "pridepack build",
32 | "type-check": "pridepack check",
33 | "clean": "pridepack clean",
34 | "test": "vitest"
35 | },
36 | "description": "Isomorphic server-side functions",
37 | "repository": {
38 | "url": "https://github.com/lxsmnsyc/thaler.git",
39 | "type": "git"
40 | },
41 | "homepage": "https://github.com/lxsmnsyc/thaler/tree/main/packages/thaler",
42 | "bugs": {
43 | "url": "https://github.com/lxsmnsyc/thaler/issues"
44 | },
45 | "publishConfig": {
46 | "access": "public"
47 | },
48 | "author": "Alexis Munsayac",
49 | "private": false,
50 | "typesVersions": {
51 | "*": {
52 | "compiler": ["./dist/types/compiler/index.d.ts"],
53 | "client": ["./dist/types/client/index.d.ts"],
54 | "server": ["./dist/types/server/index.d.ts"],
55 | "utils": ["./dist/types/utils/index.d.ts"]
56 | }
57 | },
58 | "types": "./dist/types/src/index.d.ts",
59 | "main": "./dist/cjs/production/index.cjs",
60 | "module": "./dist/esm/production/index.mjs",
61 | "exports": {
62 | ".": {
63 | "development": {
64 | "require": "./dist/cjs/development/index.cjs",
65 | "import": "./dist/esm/development/index.mjs"
66 | },
67 | "require": "./dist/cjs/production/index.cjs",
68 | "import": "./dist/esm/production/index.mjs",
69 | "types": "./dist/types/src/index.d.ts"
70 | },
71 | "./compiler": {
72 | "development": {
73 | "require": "./dist/cjs/development/compiler.cjs",
74 | "import": "./dist/esm/development/compiler.mjs"
75 | },
76 | "require": "./dist/cjs/production/compiler.cjs",
77 | "import": "./dist/esm/production/compiler.mjs",
78 | "types": "./dist/types/compiler/index.d.ts"
79 | },
80 | "./client": {
81 | "development": {
82 | "require": "./dist/cjs/development/client.cjs",
83 | "import": "./dist/esm/development/client.mjs"
84 | },
85 | "require": "./dist/cjs/production/client.cjs",
86 | "import": "./dist/esm/production/client.mjs",
87 | "types": "./dist/types/client/index.d.ts"
88 | },
89 | "./server": {
90 | "development": {
91 | "require": "./dist/cjs/development/server.cjs",
92 | "import": "./dist/esm/development/server.mjs"
93 | },
94 | "require": "./dist/cjs/production/server.cjs",
95 | "import": "./dist/esm/production/server.mjs",
96 | "types": "./dist/types/server/index.d.ts"
97 | },
98 | "./utils": {
99 | "development": {
100 | "require": "./dist/cjs/development/utils.cjs",
101 | "import": "./dist/esm/development/utils.mjs"
102 | },
103 | "require": "./dist/cjs/production/utils.cjs",
104 | "import": "./dist/esm/production/utils.mjs",
105 | "types": "./dist/types/utils/index.d.ts"
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/packages/thaler/shared/types.ts:
--------------------------------------------------------------------------------
1 | export type ThalerValue = any;
2 | export type MaybePromise = T | Promise;
3 |
4 | export type MaybeArray = T | T[];
5 | export type ThalerPostParam = Record>;
6 | export type ThalerGetParam = Record>;
7 |
8 | export interface ThalerContext {
9 | request: Request;
10 | }
11 |
12 | export type ThalerServerHandler = (request: Request) => MaybePromise;
13 | export type ThalerPostHandler = (
14 | formData: P,
15 | ctx: ThalerContext,
16 | ) => MaybePromise;
17 | export type ThalerGetHandler = (
18 | search: P,
19 | ctx: ThalerContext,
20 | ) => MaybePromise;
21 |
22 | export interface ThalerResponseInit {
23 | headers: Headers;
24 | status: number;
25 | statusText: string;
26 | }
27 |
28 | export interface ThalerFunctionalContext extends ThalerContext {
29 | response: ThalerResponseInit;
30 | }
31 |
32 | export type ThalerFnHandler = (
33 | value: T,
34 | ctx: ThalerFunctionalContext,
35 | ) => MaybePromise;
36 | export type ThalerPureHandler = (
37 | value: T,
38 | ctx: ThalerFunctionalContext,
39 | ) => MaybePromise;
40 | export type ThalerLoaderHandler = (
41 | value: P,
42 | ctx: ThalerFunctionalContext,
43 | ) => MaybePromise;
44 | export type ThalerActionHandler = (
45 | value: P,
46 | ctx: ThalerFunctionalContext,
47 | ) => MaybePromise;
48 |
49 | export type ThalerGenericHandler =
50 | | ThalerServerHandler
51 | | ThalerPostHandler
52 | | ThalerGetHandler
53 | | ThalerFnHandler
54 | | ThalerPureHandler
55 | | ThalerLoaderHandler
56 | | ThalerActionHandler;
57 |
58 | export interface ThalerBaseFunction {
59 | id: string;
60 | }
61 |
62 | export interface ThalerServerFunction extends ThalerBaseFunction {
63 | type: 'server';
64 | (init: RequestInit): Promise;
65 | }
66 |
67 | export type ThalerPostInit = Omit;
68 |
69 | export interface ThalerPostFunction
70 | extends ThalerBaseFunction {
71 | type: 'post';
72 | (formData: P, init?: ThalerPostInit): Promise;
73 | }
74 |
75 | export type ThalerGetInit = Omit;
76 |
77 | export interface ThalerGetFunction
78 | extends ThalerBaseFunction {
79 | type: 'get';
80 | (search: P, init?: ThalerGetInit): Promise;
81 | }
82 |
83 | export type ThalerFunctionInit = Omit;
84 |
85 | export interface ThalerFunction extends ThalerBaseFunction {
86 | (value: T, init?: ThalerFunctionInit): Promise;
87 | type: 'fn';
88 | }
89 |
90 | export interface ThalerPureFunction extends ThalerBaseFunction {
91 | type: 'pure';
92 | (value: T, init?: ThalerFunctionInit): Promise;
93 | }
94 |
95 | export interface ThalerLoaderFunction
96 | extends ThalerBaseFunction {
97 | type: 'loader';
98 | (value: P, init?: ThalerFunctionInit): Promise;
99 | }
100 |
101 | export interface ThalerActionFunction
102 | extends ThalerBaseFunction {
103 | type: 'action';
104 | (value: P, init?: ThalerFunctionInit): Promise;
105 | }
106 |
107 | export type ThalerFunctions =
108 | | ThalerServerFunction
109 | | ThalerPostFunction
110 | | ThalerGetFunction
111 | | ThalerFunction
112 | | ThalerPureFunction
113 | | ThalerLoaderFunction
114 | | ThalerActionFunction;
115 |
116 | export type ThalerFunctionTypes =
117 | | 'server'
118 | | 'get'
119 | | 'post'
120 | | 'fn'
121 | | 'pure'
122 | | 'loader'
123 | | 'action';
124 |
--------------------------------------------------------------------------------
/packages/thaler/shared/utils.ts:
--------------------------------------------------------------------------------
1 | import { fromJSON, toJSONAsync } from 'seroval';
2 | import {
3 | CustomEventPlugin,
4 | DOMExceptionPlugin,
5 | EventPlugin,
6 | FormDataPlugin,
7 | HeadersPlugin,
8 | ReadableStreamPlugin,
9 | RequestPlugin,
10 | ResponsePlugin,
11 | URLSearchParamsPlugin,
12 | URLPlugin,
13 | } from 'seroval-plugins/web';
14 | import type {
15 | ThalerPostParam,
16 | ThalerFunctionTypes,
17 | ThalerGetParam,
18 | } from './types';
19 |
20 | export const XThalerRequestType = 'X-Thaler-Request-Type';
21 | export const XThalerInstance = 'X-Thaler-Instance';
22 | export const XThalerID = 'X-Thaler-ID';
23 |
24 | let INSTANCE = 0;
25 |
26 | function getInstance(): string {
27 | return `thaler:${INSTANCE++}`;
28 | }
29 |
30 | export function patchHeaders(
31 | type: ThalerFunctionTypes,
32 | id: string,
33 | init: RequestInit,
34 | ): string {
35 | const instance = getInstance();
36 | if (init.headers) {
37 | const header = new Headers(init.headers);
38 | header.set(XThalerRequestType, type);
39 | header.set(XThalerInstance, instance);
40 | header.set(XThalerID, id);
41 | init.headers = header;
42 | } else {
43 | init.headers = {
44 | [XThalerRequestType]: type,
45 | [XThalerInstance]: instance,
46 | [XThalerID]: id,
47 | };
48 | }
49 | return instance;
50 | }
51 |
52 | export interface FunctionBody {
53 | scope: unknown[];
54 | value: unknown;
55 | }
56 |
57 | export async function serializeFunctionBody(
58 | body: FunctionBody,
59 | ): Promise {
60 | return JSON.stringify(
61 | await toJSONAsync(body, {
62 | plugins: [
63 | CustomEventPlugin,
64 | DOMExceptionPlugin,
65 | EventPlugin,
66 | FormDataPlugin,
67 | HeadersPlugin,
68 | ReadableStreamPlugin,
69 | RequestPlugin,
70 | ResponsePlugin,
71 | URLSearchParamsPlugin,
72 | URLPlugin,
73 | ],
74 | }),
75 | );
76 | }
77 |
78 | export function deserializeData(data: any): T {
79 | return fromJSON(data, {
80 | plugins: [
81 | CustomEventPlugin,
82 | DOMExceptionPlugin,
83 | EventPlugin,
84 | FormDataPlugin,
85 | HeadersPlugin,
86 | ReadableStreamPlugin,
87 | RequestPlugin,
88 | ResponsePlugin,
89 | URLSearchParamsPlugin,
90 | URLPlugin,
91 | ],
92 | }) as T;
93 | }
94 |
95 | export function fromFormData(formData: FormData): T {
96 | const source: ThalerPostParam = {};
97 | formData.forEach((value, key) => {
98 | if (key in source) {
99 | const current = source[key];
100 | if (Array.isArray(current)) {
101 | current.push(value);
102 | } else {
103 | source[key] = [current, value];
104 | }
105 | } else {
106 | source[key] = value;
107 | }
108 | });
109 | return source as T;
110 | }
111 |
112 | export function toFormData(source: T): FormData {
113 | const formData = new FormData();
114 | for (const [key, value] of Object.entries(source)) {
115 | if (Array.isArray(value)) {
116 | for (const item of value) {
117 | if (typeof item === 'string') {
118 | formData.append(key, item);
119 | } else {
120 | formData.append(key, item, item.name);
121 | }
122 | }
123 | } else if (typeof value === 'string') {
124 | formData.append(key, value);
125 | } else {
126 | formData.append(key, value, value.name);
127 | }
128 | }
129 | return formData;
130 | }
131 |
132 | export function fromURLSearchParams(
133 | search: URLSearchParams,
134 | ): T {
135 | const source: ThalerGetParam = {};
136 | for (const [key, value] of search.entries()) {
137 | if (key in source) {
138 | const current = source[key];
139 | if (Array.isArray(current)) {
140 | current.push(value);
141 | } else {
142 | source[key] = [current, value];
143 | }
144 | } else {
145 | source[key] = value;
146 | }
147 | }
148 | return source as T;
149 | }
150 |
151 | export function toURLSearchParams(
152 | source: T,
153 | ): URLSearchParams {
154 | const search = new URLSearchParams();
155 | for (const [key, value] of Object.entries(source)) {
156 | if (Array.isArray(value)) {
157 | for (const item of value) {
158 | search.append(key, item);
159 | }
160 | } else {
161 | search.append(key, value);
162 | }
163 | }
164 | search.sort();
165 | return search;
166 | }
167 |
--------------------------------------------------------------------------------
/packages/thaler/test/compiler.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest';
2 | import type { Options } from '../compiler';
3 | import compile from '../compiler';
4 |
5 | const functions: Options['functions'] = [
6 | {
7 | name: 'example$',
8 | scoping: true,
9 | target: {
10 | name: 'example$',
11 | source: 'example-server-function',
12 | kind: 'named',
13 | },
14 | server: {
15 | name: '$$example',
16 | source: 'example-server-function/server',
17 | kind: 'named',
18 | },
19 | client: {
20 | name: '$$example',
21 | source: 'example-server-function/client',
22 | kind: 'named',
23 | },
24 | },
25 | ];
26 |
27 | const serverOptions: Options = {
28 | prefix: 'example',
29 | mode: 'server',
30 | functions,
31 | };
32 |
33 | const clientOptions: Options = {
34 | prefix: 'example',
35 | mode: 'client',
36 | functions,
37 | };
38 |
39 | const FILE = 'src/index.ts';
40 |
41 | describe('server$', () => {
42 | it('should transform', async () => {
43 | const code = `
44 | import { server$ } from 'thaler';
45 |
46 | const example = server$((request) => {
47 | return new Response('Hello World', {
48 | headers: {
49 | 'content-type': 'text/html',
50 | },
51 | status: 200,
52 | });
53 | });
54 | `;
55 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
56 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
57 | });
58 | });
59 | describe('get$', () => {
60 | it('should transform', async () => {
61 | const code = `
62 | import { get$ } from 'thaler';
63 |
64 | const example = get$(({ greeting, receiver}) => {
65 | const message = greeting + ', ' + receiver + '!';
66 | return new Response(message, {
67 | headers: {
68 | 'content-type': 'text/html',
69 | },
70 | status: 200,
71 | });
72 | });
73 | `;
74 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
75 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
76 | });
77 | });
78 | describe('post$', () => {
79 | it('should transform', async () => {
80 | const code = `
81 | import { post$ } from 'thaler';
82 |
83 | const example = post$(({ greeting, receiver }) => {
84 | const message = greeting + ', ' + receiver + '!';
85 | return new Response(message, {
86 | headers: {
87 | 'content-type': 'text/html',
88 | },
89 | status: 200,
90 | });
91 | });
92 | `;
93 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
94 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
95 | });
96 | });
97 | describe('fn$', () => {
98 | it('should transform', async () => {
99 | const code = `
100 | import { fn$ } from 'thaler';
101 |
102 | const PREFIX = 'Message: ';
103 |
104 | const example = fn$(({ greeting, receiver }) => {
105 | const message = PREFIX + greeting + ', ' + receiver + '!';
106 | return message;
107 | });
108 | `;
109 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
110 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
111 | });
112 | it('should transform with local scope', async () => {
113 | const code = `
114 | import { fn$ } from 'thaler';
115 |
116 | function test() {
117 | const PREFIX = 'Message: ';
118 |
119 | const example = fn$(({ greeting, receiver }) => {
120 | const message = PREFIX + greeting + ', ' + receiver + '!';
121 | return message;
122 | });
123 | }
124 | `;
125 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
126 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
127 | });
128 | });
129 |
130 | describe('pure$', () => {
131 | it('should transform', async () => {
132 | const code = `
133 | import { pure$ } from 'thaler';
134 |
135 | const sleep = (ms) => new Promise((res) => {
136 | setTimeout(res, ms, true);
137 | });
138 |
139 | const example = pure$(async ({ greeting, receiver }) => {
140 | await sleep(1000);
141 | const message = greeting + ', ' + receiver + '!';
142 | return message;
143 | });
144 | `;
145 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
146 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
147 | });
148 | });
149 |
150 | describe('ref$', () => {
151 | it('should transform', async () => {
152 | const code = `
153 | import { ref$ } from 'thaler';
154 |
155 | const example = ref$(() => 'Hello World');
156 | `;
157 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
158 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
159 | });
160 | });
161 |
162 | describe('loader$', () => {
163 | it('should transform', async () => {
164 | const code = `
165 | import { loader$ } from 'thaler';
166 |
167 | const example = loader$(async ({ greeting, receiver }) => {
168 | const message = greeting + ', ' + receiver + '!';
169 | return message;
170 | });
171 | `;
172 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
173 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
174 | });
175 | });
176 |
177 | describe('action$', () => {
178 | it('should transform', async () => {
179 | const code = `
180 | import { action$ } from 'thaler';
181 |
182 | const example = action$(async ({ greeting, receiver }) => {
183 | const message = greeting + ', ' + receiver + '!';
184 | return message;
185 | });
186 | `;
187 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
188 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
189 | });
190 | });
191 |
192 | describe('custom server function', () => {
193 | it('should transform', async () => {
194 | const code = `
195 | import { example$ } from 'example-server-function';
196 |
197 | function exampleProgram() {
198 | const greeting = 'Hello';
199 | const receiver = 'World';
200 |
201 | const example = example$(() => {
202 | const message = greeting + ', ' + receiver + '!';
203 | return message;
204 | });
205 | }
206 | `;
207 | expect((await compile(FILE, code, serverOptions)).code).toMatchSnapshot();
208 | expect((await compile(FILE, code, clientOptions)).code).toMatchSnapshot();
209 | });
210 | });
211 |
--------------------------------------------------------------------------------
/packages/thaler/utils/index.ts:
--------------------------------------------------------------------------------
1 | export function json(data: T, init: ResponseInit = {}): Response {
2 | return new Response(JSON.stringify(data), {
3 | status: 200,
4 | ...init,
5 | headers: {
6 | ...init.headers,
7 | 'Content-Type': 'application/json',
8 | },
9 | });
10 | }
11 |
12 | export function text(data: string, init: ResponseInit = {}): Response {
13 | return new Response(data, {
14 | status: 200,
15 | ...init,
16 | headers: {
17 | ...init.headers,
18 | 'Content-Type': 'text/plain',
19 | },
20 | });
21 | }
22 |
23 | interface Deferred {
24 | promise: Promise;
25 | resolve(value: T): void;
26 | reject(value: unknown): void;
27 | }
28 |
29 | function createDeferred(): Deferred {
30 | let resolve: (value: T) => void;
31 | let reject: (value: unknown) => void;
32 |
33 | return {
34 | promise: new Promise((res, rej) => {
35 | resolve = res;
36 | reject = rej;
37 | }),
38 | resolve(value): void {
39 | resolve(value);
40 | },
41 | reject(value): void {
42 | reject(value);
43 | },
44 | };
45 | }
46 |
47 | const DEFAULT_DEBOUNCE_TIMEOUT = 250;
48 |
49 | export interface DebounceOptions {
50 | timeout?: number;
51 | key: (...args: T) => string;
52 | }
53 |
54 | interface DebounceData {
55 | deferred: Deferred;
56 | timeout: ReturnType;
57 | }
58 |
59 | export function debounce Promise>(
60 | callback: T,
61 | options: DebounceOptions>,
62 | ): T {
63 | const cache = new Map>>();
64 |
65 | function resolveData(
66 | current: DebounceData>,
67 | key: string,
68 | args: Parameters,
69 | ): void {
70 | const instance = current.timeout;
71 | try {
72 | callback.apply(callback, args).then(
73 | value => {
74 | if (instance === current.timeout) {
75 | current.deferred.resolve(value as ReturnType);
76 | cache.delete(key);
77 | }
78 | },
79 | value => {
80 | current.deferred.reject(value);
81 | cache.delete(key);
82 | },
83 | );
84 | } catch (err) {
85 | if (instance === current.timeout) {
86 | current.deferred.reject(err);
87 | cache.delete(key);
88 | } else {
89 | throw err;
90 | }
91 | }
92 | }
93 |
94 | return ((...args: Parameters): ReturnType => {
95 | const key = options.key(...args);
96 | let current = cache.get(key);
97 | if (current) {
98 | clearTimeout(current.timeout);
99 | current.timeout = setTimeout(() => {
100 | resolveData(current!, key, args);
101 | }, options.timeout || DEFAULT_DEBOUNCE_TIMEOUT);
102 | } else {
103 | const record: DebounceData> = {
104 | deferred: createDeferred(),
105 | timeout: setTimeout(() => {
106 | resolveData(record, key, args);
107 | }, options.timeout || DEFAULT_DEBOUNCE_TIMEOUT),
108 | };
109 | current = record;
110 | }
111 | cache.set(key, current);
112 | return current.deferred.promise as ReturnType;
113 | }) as unknown as T;
114 | }
115 |
116 | export interface ThrottleOptions {
117 | key: (...args: T) => string;
118 | }
119 |
120 | interface ThrottleData {
121 | deferred: Deferred;
122 | }
123 |
124 | export function throttle Promise>(
125 | callback: T,
126 | options: ThrottleOptions>,
127 | ): T {
128 | const cache = new Map>>();
129 |
130 | function resolveData(
131 | current: ThrottleData>,
132 | key: string,
133 | args: Parameters,
134 | ): void {
135 | try {
136 | callback.apply(callback, args).then(
137 | value => {
138 | current.deferred.resolve(value as ReturnType);
139 | cache.delete(key);
140 | },
141 | value => {
142 | current.deferred.reject(value);
143 | cache.delete(key);
144 | },
145 | );
146 | } catch (err) {
147 | current.deferred.reject(err);
148 | cache.delete(key);
149 | }
150 | }
151 |
152 | return ((...args: Parameters): ReturnType => {
153 | const key = options.key(...args);
154 | const current = cache.get(key);
155 | if (current) {
156 | return current.deferred.promise as ReturnType;
157 | }
158 | const record: ThrottleData> = {
159 | deferred: createDeferred(),
160 | };
161 | cache.set(key, record);
162 | resolveData(record, key, args);
163 | return record.deferred.promise as ReturnType;
164 | }) as unknown as T;
165 | }
166 |
167 | export interface RetryOptions {
168 | count?: number;
169 | interval?: number;
170 | }
171 |
172 | const DEFAULT_RETRY_COUNT = 10;
173 | const DEFAULT_RETRY_INTERVAL = 5000;
174 | const INITIAL_RETRY_INTERVAL = 10;
175 |
176 | export function retry Promise>(
177 | callback: T,
178 | options: RetryOptions,
179 | ): T {
180 | const opts = {
181 | count: options.count == null ? DEFAULT_RETRY_COUNT : options.count,
182 | interval: options.interval || DEFAULT_RETRY_INTERVAL,
183 | };
184 | function resolveData(
185 | deferred: Deferred>,
186 | args: Parameters,
187 | ): void {
188 | function backoff(time: number, count: number): void {
189 | function handleError(reason: unknown): void {
190 | if (opts.count <= count) {
191 | deferred.reject(reason);
192 | } else {
193 | setTimeout(() => {
194 | backoff(
195 | Math.max(
196 | INITIAL_RETRY_INTERVAL,
197 | Math.min(opts.interval, time * 2),
198 | ),
199 | count + 1,
200 | );
201 | }, time);
202 | }
203 | }
204 | try {
205 | callback.apply(callback, args).then(value => {
206 | deferred.resolve(value as ReturnType);
207 | }, handleError);
208 | } catch (err) {
209 | handleError(err);
210 | }
211 | }
212 | backoff(INITIAL_RETRY_INTERVAL, 0);
213 | }
214 |
215 | return ((...args: Parameters): ReturnType => {
216 | const deferred = createDeferred>();
217 | resolveData(deferred, args);
218 | return deferred.promise as ReturnType;
219 | }) as unknown as T;
220 | }
221 |
222 | export function timeout Promise>(
223 | callback: T,
224 | ms: number,
225 | ): T {
226 | return ((...args: Parameters): ReturnType => {
227 | const deferred = createDeferred>();
228 | const timer = setTimeout(() => {
229 | deferred.reject(new Error('request timeout'));
230 | }, ms);
231 |
232 | try {
233 | callback.apply(callback, args).then(
234 | value => {
235 | deferred.resolve(value as ReturnType);
236 | clearTimeout(timer);
237 | },
238 | value => {
239 | deferred.reject(value);
240 | clearTimeout(timer);
241 | },
242 | );
243 | } catch (error) {
244 | deferred.reject(error);
245 | clearTimeout(timer);
246 | }
247 | return deferred.promise as ReturnType;
248 | }) as unknown as T;
249 | }
250 |
--------------------------------------------------------------------------------
/packages/thaler/compiler/xxhash32.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2019 Jason Dent
3 | * https://github.com/Jason3S/xxhash
4 | */
5 | const PRIME32_1 = 2654435761;
6 | const PRIME32_2 = 2246822519;
7 | const PRIME32_3 = 3266489917;
8 | const PRIME32_4 = 668265263;
9 | const PRIME32_5 = 374761393;
10 |
11 | function toUtf8(text: string): Uint8Array {
12 | const bytes: number[] = [];
13 | for (let i = 0, n = text.length; i < n; ++i) {
14 | const c = text.charCodeAt(i);
15 | if (c < 0x80) {
16 | bytes.push(c);
17 | } else if (c < 0x800) {
18 | bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
19 | } else if (c < 0xd800 || c >= 0xe000) {
20 | bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
21 | } else {
22 | const cp = 0x10000 + (((c & 0x3ff) << 10) | (text.charCodeAt(++i) & 0x3ff));
23 | bytes.push(
24 | 0xf0 | ((cp >> 18) & 0x7),
25 | 0x80 | ((cp >> 12) & 0x3f),
26 | 0x80 | ((cp >> 6) & 0x3f),
27 | 0x80 | (cp & 0x3f),
28 | );
29 | }
30 | }
31 | return new Uint8Array(bytes);
32 | }
33 | /**
34 | *
35 | * @param buffer - byte array or string
36 | * @param seed - optional seed (32-bit unsigned);
37 | */
38 | export default function xxHash32(buffer: Uint8Array | string, seed = 0): number {
39 | buffer = typeof buffer === 'string' ? toUtf8(buffer) : buffer;
40 | const b = buffer;
41 |
42 | /*
43 | Step 1. Initialize internal accumulators
44 | Each accumulator gets an initial value based on optional seed input.
45 | Since the seed is optional, it can be 0.
46 | ```
47 | u32 acc1 = seed + PRIME32_1 + PRIME32_2;
48 | u32 acc2 = seed + PRIME32_2;
49 | u32 acc3 = seed + 0;
50 | u32 acc4 = seed - PRIME32_1;
51 | ```
52 | Special case : input is less than 16 bytes
53 | When input is too small (< 16 bytes), the algorithm will not process any stripe.
54 | Consequently, it will not make use of parallel accumulators.
55 | In which case, a simplified initialization is performed, using a single accumulator :
56 | u32 acc = seed + PRIME32_5;
57 | The algorithm then proceeds directly to step 4.
58 | */
59 |
60 | let acc = (seed + PRIME32_5) & 0xffffffff;
61 | let offset = 0;
62 |
63 | if (b.length >= 16) {
64 | const accN = [
65 | (seed + PRIME32_1 + PRIME32_2) & 0xffffffff,
66 | (seed + PRIME32_2) & 0xffffffff,
67 | (seed + 0) & 0xffffffff,
68 | (seed - PRIME32_1) & 0xffffffff,
69 | ];
70 |
71 | /*
72 | Step 2. Process stripes
73 | A stripe is a contiguous segment of 16 bytes. It is evenly divided into 4 lanes,
74 | of 4 bytes each. The first lane is used to update accumulator 1, the second lane
75 | is used to update accumulator 2, and so on. Each lane read its associated 32-bit
76 | value using little-endian convention. For each {lane, accumulator}, the update
77 | process is called a round, and applies the following formula :
78 | ```
79 | accN = accN + (laneN * PRIME32_2);
80 | accN = accN <<< 13;
81 | accN = accN * PRIME32_1;
82 | ```
83 | This shuffles the bits so that any bit from input lane impacts several bits in
84 | output accumulator. All operations are performed modulo 2^32.
85 | Input is consumed one full stripe at a time. Step 2 is looped as many times as
86 | necessary to consume the whole input, except the last remaining bytes which cannot
87 | form a stripe (< 16 bytes). When that happens, move to step 3.
88 | */
89 |
90 | const b = buffer;
91 | const limit = b.length - 16;
92 | let lane = 0;
93 | for (offset = 0; (offset & 0xfffffff0) <= limit; offset += 4) {
94 | const i = offset;
95 | const laneN0 = b[i + 0] + (b[i + 1] << 8);
96 | const laneN1 = b[i + 2] + (b[i + 3] << 8);
97 | const laneNP = laneN0 * PRIME32_2 + ((laneN1 * PRIME32_2) << 16);
98 | let acc = (accN[lane] + laneNP) & 0xffffffff;
99 | acc = (acc << 13) | (acc >>> 19);
100 | const acc0 = acc & 0xffff;
101 | const acc1 = acc >>> 16;
102 | accN[lane] = (acc0 * PRIME32_1 + ((acc1 * PRIME32_1) << 16)) & 0xffffffff;
103 | lane = (lane + 1) & 0x3;
104 | }
105 |
106 | /*
107 | Step 3. Accumulator convergence
108 | All 4 lane accumulators from previous steps are merged to produce a
109 | single remaining accumulator
110 | of same width (32-bit). The associated formula is as follows :
111 | ```
112 | acc = (acc1 <<< 1) + (acc2 <<< 7) + (acc3 <<< 12) + (acc4 <<< 18);
113 | ```
114 | */
115 | acc = (((accN[0] << 1) | (accN[0] >>> 31))
116 | + ((accN[1] << 7) | (accN[1] >>> 25))
117 | + ((accN[2] << 12) | (accN[2] >>> 20))
118 | + ((accN[3] << 18) | (accN[3] >>> 14)))
119 | & 0xffffffff;
120 | }
121 |
122 | /*
123 | Step 4. Add input length
124 | The input total length is presumed known at this stage.
125 | This step is just about adding the length to
126 | accumulator, so that it participates to final mixing.
127 | ```
128 | acc = acc + (u32)inputLength;
129 | ```
130 | */
131 | acc = (acc + buffer.length) & 0xffffffff;
132 |
133 | /*
134 | Step 5. Consume remaining input
135 | There may be up to 15 bytes remaining to consume from the input.
136 | The final stage will digest them according
137 | to following pseudo-code :
138 | ```
139 | while (remainingLength >= 4) {
140 | lane = read_32bit_little_endian(input_ptr);
141 | acc = acc + lane * PRIME32_3;
142 | acc = (acc <<< 17) * PRIME32_4;
143 | input_ptr += 4; remainingLength -= 4;
144 | }
145 | ```
146 | This process ensures that all input bytes are present in the final mix.
147 | */
148 |
149 | const limit = buffer.length - 4;
150 | for (; offset <= limit; offset += 4) {
151 | const i = offset;
152 | const laneN0 = b[i + 0] + (b[i + 1] << 8);
153 | const laneN1 = b[i + 2] + (b[i + 3] << 8);
154 | const laneP = laneN0 * PRIME32_3 + ((laneN1 * PRIME32_3) << 16);
155 | acc = (acc + laneP) & 0xffffffff;
156 | acc = (acc << 17) | (acc >>> 15);
157 | acc = ((acc & 0xffff) * PRIME32_4 + (((acc >>> 16) * PRIME32_4) << 16)) & 0xffffffff;
158 | }
159 |
160 | /*
161 | ```
162 | while (remainingLength >= 1) {
163 | lane = read_byte(input_ptr);
164 | acc = acc + lane * PRIME32_5;
165 | acc = (acc <<< 11) * PRIME32_1;
166 | input_ptr += 1; remainingLength -= 1;
167 | }
168 | ```
169 | */
170 |
171 | for (; offset < b.length; ++offset) {
172 | const lane = b[offset];
173 | acc += lane * PRIME32_5;
174 | acc = (acc << 11) | (acc >>> 21);
175 | acc = ((acc & 0xffff) * PRIME32_1 + (((acc >>> 16) * PRIME32_1) << 16)) & 0xffffffff;
176 | }
177 |
178 | /*
179 | Step 6. Final mix (avalanche)
180 | The final mix ensures that all input bits have a chance to impact any bit in
181 | the output digest, resulting in an unbiased distribution. This is also called
182 | avalanche effect.
183 | ```
184 | acc = acc xor (acc >> 15);
185 | acc = acc * PRIME32_2;
186 | acc = acc xor (acc >> 13);
187 | acc = acc * PRIME32_3;
188 | acc = acc xor (acc >> 16);
189 | ```
190 | */
191 |
192 | acc ^= (acc >>> 15);
193 | acc = (((acc & 0xffff) * PRIME32_2) & 0xffffffff) + (((acc >>> 16) * PRIME32_2) << 16);
194 | acc ^= (acc >>> 13);
195 | acc = (((acc & 0xffff) * PRIME32_3) & 0xffffffff) + (((acc >>> 16) * PRIME32_3) << 16);
196 | acc ^= (acc >>> 16);
197 |
198 | // turn any negatives back into a positive number;
199 | return acc < 0 ? acc + 4294967296 : acc;
200 | }
201 |
--------------------------------------------------------------------------------
/packages/thaler/test/__snapshots__/compiler.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`action$ > should transform 1`] = `
4 | "import { $$clone as _$$clone } from "thaler/server";
5 | import { $$action as _$$action } from "thaler/server";
6 | import { action$ } from 'thaler';
7 | const _action$ = _$$action("/example/f0b3b6fa-index-0-example", async ({
8 | greeting,
9 | receiver
10 | }) => {
11 | const message = greeting + ', ' + receiver + '!';
12 | return message;
13 | });
14 | const example = _$$clone(_action$);"
15 | `;
16 |
17 | exports[`action$ > should transform 2`] = `
18 | "import { $$clone as _$$clone } from "thaler/client";
19 | import { $$action as _$$action } from "thaler/client";
20 | import { action$ } from 'thaler';
21 | const _action$ = _$$action("/example/f0b3b6fa-index-0-example");
22 | const example = _$$clone(_action$);"
23 | `;
24 |
25 | exports[`custom server function > should transform 1`] = `
26 | "import { $$clone as _$$clone } from "thaler/server";
27 | import { $$scope as _$$scope } from "thaler/server";
28 | import { $$example as _$$example } from "example-server-function/server";
29 | import { example$ } from 'example-server-function';
30 | const _example$ = _$$example("/example/f0b3b6fa-index-0-example", () => {
31 | const [greeting, receiver] = _$$scope();
32 | const message = greeting + ', ' + receiver + '!';
33 | return message;
34 | });
35 | function exampleProgram() {
36 | const greeting = 'Hello';
37 | const receiver = 'World';
38 | const example = _$$clone(_example$, () => [greeting, receiver]);
39 | }"
40 | `;
41 |
42 | exports[`custom server function > should transform 2`] = `
43 | "import { $$clone as _$$clone } from "thaler/client";
44 | import { $$example as _$$example } from "example-server-function/client";
45 | import { example$ } from 'example-server-function';
46 | const _example$ = _$$example("/example/f0b3b6fa-index-0-example");
47 | function exampleProgram() {
48 | const greeting = 'Hello';
49 | const receiver = 'World';
50 | const example = _$$clone(_example$, () => [greeting, receiver]);
51 | }"
52 | `;
53 |
54 | exports[`fn$ > should transform 1`] = `
55 | "import { $$clone as _$$clone } from "thaler/server";
56 | import { $$fn as _$$fn } from "thaler/server";
57 | import { fn$ } from 'thaler';
58 | const PREFIX = 'Message: ';
59 | const _fn$ = _$$fn("/example/f0b3b6fa-index-0-example", ({
60 | greeting,
61 | receiver
62 | }) => {
63 | const message = PREFIX + greeting + ', ' + receiver + '!';
64 | return message;
65 | });
66 | const example = _$$clone(_fn$, () => []);"
67 | `;
68 |
69 | exports[`fn$ > should transform 2`] = `
70 | "import { $$clone as _$$clone } from "thaler/client";
71 | import { $$fn as _$$fn } from "thaler/client";
72 | import { fn$ } from 'thaler';
73 | const PREFIX = 'Message: ';
74 | const _fn$ = _$$fn("/example/f0b3b6fa-index-0-example");
75 | const example = _$$clone(_fn$, () => []);"
76 | `;
77 |
78 | exports[`fn$ > should transform with local scope 1`] = `
79 | "import { $$clone as _$$clone } from "thaler/server";
80 | import { $$scope as _$$scope } from "thaler/server";
81 | import { $$fn as _$$fn } from "thaler/server";
82 | import { fn$ } from 'thaler';
83 | const _fn$ = _$$fn("/example/f0b3b6fa-index-0-example", ({
84 | greeting,
85 | receiver
86 | }) => {
87 | const [PREFIX] = _$$scope();
88 | const message = PREFIX + greeting + ', ' + receiver + '!';
89 | return message;
90 | });
91 | function test() {
92 | const PREFIX = 'Message: ';
93 | const example = _$$clone(_fn$, () => [PREFIX]);
94 | }"
95 | `;
96 |
97 | exports[`fn$ > should transform with local scope 2`] = `
98 | "import { $$clone as _$$clone } from "thaler/client";
99 | import { $$fn as _$$fn } from "thaler/client";
100 | import { fn$ } from 'thaler';
101 | const _fn$ = _$$fn("/example/f0b3b6fa-index-0-example");
102 | function test() {
103 | const PREFIX = 'Message: ';
104 | const example = _$$clone(_fn$, () => [PREFIX]);
105 | }"
106 | `;
107 |
108 | exports[`get$ > should transform 1`] = `
109 | "import { $$clone as _$$clone } from "thaler/server";
110 | import { $$get as _$$get } from "thaler/server";
111 | import { get$ } from 'thaler';
112 | const _get$ = _$$get("/example/f0b3b6fa-index-0-example", ({
113 | greeting,
114 | receiver
115 | }) => {
116 | const message = greeting + ', ' + receiver + '!';
117 | return new Response(message, {
118 | headers: {
119 | 'content-type': 'text/html'
120 | },
121 | status: 200
122 | });
123 | });
124 | const example = _$$clone(_get$);"
125 | `;
126 |
127 | exports[`get$ > should transform 2`] = `
128 | "import { $$clone as _$$clone } from "thaler/client";
129 | import { $$get as _$$get } from "thaler/client";
130 | import { get$ } from 'thaler';
131 | const _get$ = _$$get("/example/f0b3b6fa-index-0-example");
132 | const example = _$$clone(_get$);"
133 | `;
134 |
135 | exports[`loader$ > should transform 1`] = `
136 | "import { $$clone as _$$clone } from "thaler/server";
137 | import { $$loader as _$$loader } from "thaler/server";
138 | import { loader$ } from 'thaler';
139 | const _loader$ = _$$loader("/example/f0b3b6fa-index-0-example", async ({
140 | greeting,
141 | receiver
142 | }) => {
143 | const message = greeting + ', ' + receiver + '!';
144 | return message;
145 | });
146 | const example = _$$clone(_loader$);"
147 | `;
148 |
149 | exports[`loader$ > should transform 2`] = `
150 | "import { $$clone as _$$clone } from "thaler/client";
151 | import { $$loader as _$$loader } from "thaler/client";
152 | import { loader$ } from 'thaler';
153 | const _loader$ = _$$loader("/example/f0b3b6fa-index-0-example");
154 | const example = _$$clone(_loader$);"
155 | `;
156 |
157 | exports[`post$ > should transform 1`] = `
158 | "import { $$clone as _$$clone } from "thaler/server";
159 | import { $$post as _$$post } from "thaler/server";
160 | import { post$ } from 'thaler';
161 | const _post$ = _$$post("/example/f0b3b6fa-index-0-example", ({
162 | greeting,
163 | receiver
164 | }) => {
165 | const message = greeting + ', ' + receiver + '!';
166 | return new Response(message, {
167 | headers: {
168 | 'content-type': 'text/html'
169 | },
170 | status: 200
171 | });
172 | });
173 | const example = _$$clone(_post$);"
174 | `;
175 |
176 | exports[`post$ > should transform 2`] = `
177 | "import { $$clone as _$$clone } from "thaler/client";
178 | import { $$post as _$$post } from "thaler/client";
179 | import { post$ } from 'thaler';
180 | const _post$ = _$$post("/example/f0b3b6fa-index-0-example");
181 | const example = _$$clone(_post$);"
182 | `;
183 |
184 | exports[`pure$ > should transform 1`] = `
185 | "import { $$clone as _$$clone } from "thaler/server";
186 | import { $$pure as _$$pure } from "thaler/server";
187 | import { pure$ } from 'thaler';
188 | const sleep = ms => new Promise(res => {
189 | setTimeout(res, ms, true);
190 | });
191 | const _pure$ = _$$pure("/example/f0b3b6fa-index-0-example", async ({
192 | greeting,
193 | receiver
194 | }) => {
195 | await sleep(1000);
196 | const message = greeting + ', ' + receiver + '!';
197 | return message;
198 | });
199 | const example = _$$clone(_pure$);"
200 | `;
201 |
202 | exports[`pure$ > should transform 2`] = `
203 | "import { $$clone as _$$clone } from "thaler/client";
204 | import { $$pure as _$$pure } from "thaler/client";
205 | import { pure$ } from 'thaler';
206 | const sleep = ms => new Promise(res => {
207 | setTimeout(res, ms, true);
208 | });
209 | const _pure$ = _$$pure("/example/f0b3b6fa-index-0-example");
210 | const example = _$$clone(_pure$);"
211 | `;
212 |
213 | exports[`ref$ > should transform 1`] = `
214 | "import { $$ref as _$$ref } from "thaler/server";
215 | import { ref$ } from 'thaler';
216 | const example = _$$ref("/example/f0b3b6fa-index-0", () => 'Hello World');"
217 | `;
218 |
219 | exports[`ref$ > should transform 2`] = `
220 | "import { $$ref as _$$ref } from "thaler/client";
221 | import { ref$ } from 'thaler';
222 | const example = _$$ref("/example/f0b3b6fa-index-0", () => 'Hello World');"
223 | `;
224 |
225 | exports[`server$ > should transform 1`] = `
226 | "import { $$clone as _$$clone } from "thaler/server";
227 | import { $$server as _$$server } from "thaler/server";
228 | import { server$ } from 'thaler';
229 | const _server$ = _$$server("/example/f0b3b6fa-index-0-example", request => {
230 | return new Response('Hello World', {
231 | headers: {
232 | 'content-type': 'text/html'
233 | },
234 | status: 200
235 | });
236 | });
237 | const example = _$$clone(_server$);"
238 | `;
239 |
240 | exports[`server$ > should transform 2`] = `
241 | "import { $$clone as _$$clone } from "thaler/client";
242 | import { $$server as _$$server } from "thaler/client";
243 | import { server$ } from 'thaler';
244 | const _server$ = _$$server("/example/f0b3b6fa-index-0-example");
245 | const example = _$$clone(_server$);"
246 | `;
247 |
--------------------------------------------------------------------------------
/packages/thaler/client/index.ts:
--------------------------------------------------------------------------------
1 | import { createReference, deserialize, toJSONAsync } from 'seroval';
2 | import ThalerError from '../shared/error';
3 | import type {
4 | ThalerPostInit,
5 | ThalerPostParam,
6 | ThalerFunctionInit,
7 | ThalerFunctions,
8 | ThalerFunctionTypes,
9 | ThalerGetInit,
10 | ThalerGetParam,
11 | MaybePromise,
12 | } from '../shared/types';
13 | import {
14 | XThalerID,
15 | XThalerInstance,
16 | patchHeaders,
17 | serializeFunctionBody,
18 | toFormData,
19 | toURLSearchParams,
20 | } from '../shared/utils';
21 |
22 | interface HandlerRegistrationResult {
23 | type: ThalerFunctionTypes;
24 | id: string;
25 | }
26 |
27 | export function $$server(id: string): HandlerRegistrationResult {
28 | return { type: 'server', id };
29 | }
30 | export function $$post(id: string): HandlerRegistrationResult {
31 | return { type: 'post', id };
32 | }
33 | export function $$get(id: string): HandlerRegistrationResult {
34 | return { type: 'get', id };
35 | }
36 | export function $$fn(id: string): HandlerRegistrationResult {
37 | return { type: 'fn', id };
38 | }
39 | export function $$pure(id: string): HandlerRegistrationResult {
40 | return { type: 'pure', id };
41 | }
42 | export function $$loader(id: string): HandlerRegistrationResult {
43 | return { type: 'loader', id };
44 | }
45 | export function $$action(id: string): HandlerRegistrationResult {
46 | return { type: 'action', id };
47 | }
48 |
49 | export type Interceptor = (request: Request) => MaybePromise;
50 |
51 | const INTERCEPTORS: Interceptor[] = [];
52 |
53 | export function interceptRequest(callback: Interceptor): void {
54 | INTERCEPTORS.push(callback);
55 | }
56 |
57 | async function serverHandler(
58 | type: ThalerFunctionTypes,
59 | id: string,
60 | init: RequestInit,
61 | ): Promise {
62 | patchHeaders(type, id, init);
63 | let root = new Request(id, init);
64 | for (const intercept of INTERCEPTORS) {
65 | root = await intercept(root);
66 | }
67 | const result = await fetch(root);
68 | return result;
69 | }
70 |
71 | async function postHandler(
72 | id: string,
73 | form: P,
74 | init: ThalerPostInit = {},
75 | ): Promise {
76 | return await serverHandler('post', id, {
77 | ...init,
78 | method: 'POST',
79 | body: toFormData(form),
80 | });
81 | }
82 |
83 | async function getHandler(
84 | id: string,
85 | search: P,
86 | init: ThalerGetInit = {},
87 | ): Promise {
88 | return await serverHandler(
89 | 'get',
90 | `${id}?${toURLSearchParams(search).toString()}`,
91 | {
92 | ...init,
93 | method: 'GET',
94 | },
95 | );
96 | }
97 |
98 | declare const $R: Record;
99 |
100 | class SerovalChunkReader {
101 | private reader: ReadableStreamDefaultReader;
102 | private buffer = '';
103 | private done = false;
104 |
105 | constructor(stream: ReadableStream) {
106 | this.reader = stream.getReader();
107 | }
108 |
109 | async readChunk(): Promise {
110 | // if there's no chunk, read again
111 | const chunk = await this.reader.read();
112 | if (chunk.done) {
113 | this.done = true;
114 | } else {
115 | // repopulate the buffer
116 | this.buffer += new TextDecoder().decode(chunk.value);
117 | }
118 | }
119 |
120 | async next(): Promise> {
121 | // Check if the buffer is empty
122 | if (this.buffer === '') {
123 | // if we are already done...
124 | if (this.done) {
125 | return {
126 | done: true,
127 | value: undefined,
128 | };
129 | }
130 | // Otherwise, read a new chunk
131 | await this.readChunk();
132 | return await this.next();
133 | }
134 | // Read the "byte header"
135 | // The byte header tells us how big the expected data is
136 | // so we know how much data we should wait before we
137 | // deserialize the data
138 | const bytes = Number.parseInt(this.buffer.substring(1, 11), 16); // ;0x00000000;
139 | // Check if the buffer has enough bytes to be parsed
140 | while (bytes > this.buffer.length - 12) {
141 | // If it's not enough, and the reader is done
142 | // then the chunk is invalid.
143 | if (this.done) {
144 | throw new Error('Malformed server function stream.');
145 | }
146 | // Otherwise, we read more chunks
147 | await this.readChunk();
148 | }
149 | // Extract the exact chunk as defined by the byte header
150 | const partial = this.buffer.substring(12, 12 + bytes);
151 | // The rest goes to the buffer
152 | this.buffer = this.buffer.substring(12 + bytes);
153 | // Deserialize the chunk
154 | return {
155 | done: false,
156 | value: deserialize(partial),
157 | };
158 | }
159 |
160 | async drain(): Promise {
161 | while (true) {
162 | const result = await this.next();
163 | if (result.done) {
164 | break;
165 | }
166 | }
167 | }
168 | }
169 |
170 | async function deserializeStream(
171 | id: string,
172 | response: Response,
173 | ): Promise {
174 | const instance = response.headers.get(XThalerInstance);
175 | const target = response.headers.get(XThalerID);
176 | if (!instance || target !== id) {
177 | throw new Error(`Invalid response for ${id}.`);
178 | }
179 | if (!response.body) {
180 | throw new Error('missing body');
181 | }
182 | const reader = new SerovalChunkReader(response.body);
183 |
184 | const result = await reader.next();
185 |
186 | if (!result.done) {
187 | reader.drain().then(
188 | () => {
189 | delete $R[instance];
190 | },
191 | () => {
192 | // no-op
193 | },
194 | );
195 | }
196 |
197 | if (response.ok) {
198 | return result.value as T;
199 | }
200 | if (import.meta.env.DEV) {
201 | throw result.value;
202 | }
203 | throw new ThalerError(id);
204 | }
205 |
206 | async function fnHandler(
207 | id: string,
208 | scope: () => unknown[],
209 | value: T,
210 | init: ThalerFunctionInit = {},
211 | ): Promise {
212 | return deserializeStream(
213 | id,
214 | await serverHandler('fn', id, {
215 | ...init,
216 | method: 'POST',
217 | body: await serializeFunctionBody({
218 | scope: scope(),
219 | value,
220 | }),
221 | }),
222 | );
223 | }
224 |
225 | async function pureHandler(
226 | id: string,
227 | value: T,
228 | init: ThalerFunctionInit = {},
229 | ): Promise {
230 | return deserializeStream(
231 | id,
232 | await serverHandler('pure', id, {
233 | ...init,
234 | method: 'POST',
235 | body: JSON.stringify(await toJSONAsync(value)),
236 | }),
237 | );
238 | }
239 |
240 | async function loaderHandler(
241 | id: string,
242 | search: P,
243 | init: ThalerGetInit = {},
244 | ): Promise {
245 | return deserializeStream(
246 | id,
247 | await serverHandler(
248 | 'loader',
249 | `${id}?${toURLSearchParams(search).toString()}`,
250 | {
251 | ...init,
252 | method: 'GET',
253 | },
254 | ),
255 | );
256 | }
257 |
258 | async function actionHandler(
259 | id: string,
260 | form: P,
261 | init: ThalerPostInit = {},
262 | ): Promise {
263 | return deserializeStream(
264 | id,
265 | await serverHandler('action', id, {
266 | ...init,
267 | method: 'POST',
268 | body: toFormData(form),
269 | }),
270 | );
271 | }
272 |
273 | export function $$clone(
274 | { type, id }: HandlerRegistrationResult,
275 | scope: () => unknown[],
276 | ): ThalerFunctions {
277 | switch (type) {
278 | case 'server':
279 | return Object.assign(serverHandler.bind(null, 'server', id), {
280 | type,
281 | id,
282 | });
283 | case 'post':
284 | return Object.assign(postHandler.bind(null, id), {
285 | type,
286 | id,
287 | });
288 | case 'get':
289 | return Object.assign(getHandler.bind(null, id), {
290 | type,
291 | id,
292 | });
293 | case 'fn':
294 | return Object.assign(fnHandler.bind(null, id, scope), {
295 | type,
296 | id,
297 | });
298 | case 'pure':
299 | return Object.assign(pureHandler.bind(null, id), {
300 | type,
301 | id,
302 | });
303 | case 'loader':
304 | return Object.assign(loaderHandler.bind(null, id), {
305 | type,
306 | id,
307 | });
308 | case 'action':
309 | return Object.assign(actionHandler.bind(null, id), {
310 | type,
311 | id,
312 | });
313 | default:
314 | throw new Error('unknown registration type');
315 | }
316 | }
317 |
318 | export function $$ref(id: string, value: T): T {
319 | return createReference(`thaler--${id}`, value);
320 | }
321 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@biomejs/biome/configuration_schema.json",
3 | "files": {
4 | "ignore": ["node_modules/**/*"]
5 | },
6 | "vcs": {
7 | "useIgnoreFile": true
8 | },
9 | "linter": {
10 | "enabled": true,
11 | "ignore": ["node_modules/**/*"],
12 | "rules": {
13 | "a11y": {
14 | "noAccessKey": "error",
15 | "noAriaHiddenOnFocusable": "off",
16 | "noAriaUnsupportedElements": "error",
17 | "noAutofocus": "error",
18 | "noBlankTarget": "error",
19 | "noDistractingElements": "error",
20 | "noHeaderScope": "error",
21 | "noInteractiveElementToNoninteractiveRole": "error",
22 | "noNoninteractiveElementToInteractiveRole": "error",
23 | "noNoninteractiveTabindex": "error",
24 | "noPositiveTabindex": "error",
25 | "noRedundantAlt": "error",
26 | "noRedundantRoles": "error",
27 | "noSvgWithoutTitle": "error",
28 | "useAltText": "error",
29 | "useAnchorContent": "error",
30 | "useAriaActivedescendantWithTabindex": "error",
31 | "useAriaPropsForRole": "error",
32 | "useButtonType": "error",
33 | "useHeadingContent": "error",
34 | "useHtmlLang": "error",
35 | "useIframeTitle": "warn",
36 | "useKeyWithClickEvents": "warn",
37 | "useKeyWithMouseEvents": "warn",
38 | "useMediaCaption": "error",
39 | "useValidAnchor": "error",
40 | "useValidAriaProps": "error",
41 | "useValidAriaRole": "error",
42 | "useValidAriaValues": "error",
43 | "useValidLang": "error"
44 | },
45 | "complexity": {
46 | "noBannedTypes": "error",
47 | "noExcessiveCognitiveComplexity": "error",
48 | "noExtraBooleanCast": "error",
49 | "noForEach": "error",
50 | "noMultipleSpacesInRegularExpressionLiterals": "warn",
51 | "noStaticOnlyClass": "error",
52 | "noThisInStatic": "error",
53 | "noUselessCatch": "error",
54 | "noUselessConstructor": "error",
55 | "noUselessEmptyExport": "error",
56 | "noUselessFragments": "error",
57 | "noUselessLabel": "error",
58 | "noUselessRename": "error",
59 | "noUselessSwitchCase": "error",
60 | "noUselessThisAlias": "error",
61 | "noUselessTypeConstraint": "error",
62 | "noVoid": "off",
63 | "noWith": "error",
64 | "useArrowFunction": "error",
65 | "useFlatMap": "error",
66 | "useLiteralKeys": "error",
67 | "useOptionalChain": "warn",
68 | "useRegexLiterals": "error",
69 | "useSimpleNumberKeys": "error",
70 | "useSimplifiedLogicExpression": "error"
71 | },
72 | "correctness": {
73 | "noChildrenProp": "error",
74 | "noConstantCondition": "error",
75 | "noConstAssign": "error",
76 | "noConstructorReturn": "error",
77 | "noEmptyCharacterClassInRegex": "error",
78 | "noEmptyPattern": "error",
79 | "noGlobalObjectCalls": "error",
80 | "noInnerDeclarations": "error",
81 | "noInvalidConstructorSuper": "error",
82 | "noInvalidNewBuiltin": "error",
83 | "noNewSymbol": "error",
84 | "noNonoctalDecimalEscape": "error",
85 | "noPrecisionLoss": "error",
86 | "noRenderReturnValue": "error",
87 | "noSelfAssign": "error",
88 | "noSetterReturn": "error",
89 | "noStringCaseMismatch": "error",
90 | "noSwitchDeclarations": "error",
91 | "noUndeclaredVariables": "error",
92 | "noUnnecessaryContinue": "error",
93 | "noUnreachable": "error",
94 | "noUnreachableSuper": "error",
95 | "noUnsafeFinally": "error",
96 | "noUnsafeOptionalChaining": "error",
97 | "noUnusedLabels": "error",
98 | "noUnusedVariables": "error",
99 | "noVoidElementsWithChildren": "error",
100 | "noVoidTypeReturn": "error",
101 | "useExhaustiveDependencies": "error",
102 | "useHookAtTopLevel": "error",
103 | "useIsNan": "error",
104 | "useValidForDirection": "error",
105 | "useYield": "error"
106 | },
107 | "performance": {
108 | "noAccumulatingSpread": "error",
109 | "noDelete": "off"
110 | },
111 | "security": {
112 | "noDangerouslySetInnerHtml": "error",
113 | "noDangerouslySetInnerHtmlWithChildren": "error"
114 | },
115 | "style": {
116 | "noArguments": "error",
117 | "noCommaOperator": "off",
118 | "noDefaultExport": "off",
119 | "noImplicitBoolean": "off",
120 | "noInferrableTypes": "error",
121 | "noNamespace": "error",
122 | "noNegationElse": "error",
123 | "noNonNullAssertion": "off",
124 | "noParameterAssign": "off",
125 | "noParameterProperties": "off",
126 | "noRestrictedGlobals": "error",
127 | "noShoutyConstants": "error",
128 | "noUnusedTemplateLiteral": "error",
129 | "noUselessElse": "error",
130 | "noVar": "error",
131 | "useAsConstAssertion": "error",
132 | "useBlockStatements": "error",
133 | "useCollapsedElseIf": "error",
134 | "useConst": "error",
135 | "useDefaultParameterLast": "error",
136 | "useEnumInitializers": "error",
137 | "useExponentiationOperator": "error",
138 | "useFragmentSyntax": "error",
139 | "useLiteralEnumMembers": "error",
140 | "useNamingConvention": "off",
141 | "useNumericLiterals": "error",
142 | "useSelfClosingElements": "error",
143 | "useShorthandArrayType": "error",
144 | "useShorthandAssign": "error",
145 | "useSingleCaseStatement": "error",
146 | "useSingleVarDeclarator": "error",
147 | "useTemplate": "off",
148 | "useWhile": "error"
149 | },
150 | "suspicious": {
151 | "noApproximativeNumericConstant": "error",
152 | "noArrayIndexKey": "error",
153 | "noAssignInExpressions": "error",
154 | "noAsyncPromiseExecutor": "error",
155 | "noCatchAssign": "error",
156 | "noClassAssign": "error",
157 | "noCommentText": "error",
158 | "noCompareNegZero": "error",
159 | "noConfusingLabels": "error",
160 | "noConfusingVoidType": "error",
161 | "noConsoleLog": "warn",
162 | "noConstEnum": "off",
163 | "noControlCharactersInRegex": "error",
164 | "noDebugger": "off",
165 | "noDoubleEquals": "error",
166 | "noDuplicateCase": "error",
167 | "noDuplicateClassMembers": "error",
168 | "noDuplicateJsxProps": "error",
169 | "noDuplicateObjectKeys": "error",
170 | "noDuplicateParameters": "error",
171 | "noEmptyInterface": "error",
172 | "noExplicitAny": "warn",
173 | "noExtraNonNullAssertion": "error",
174 | "noFallthroughSwitchClause": "error",
175 | "noFunctionAssign": "error",
176 | "noGlobalIsFinite": "error",
177 | "noGlobalIsNan": "error",
178 | "noImplicitAnyLet": "off",
179 | "noImportAssign": "error",
180 | "noLabelVar": "error",
181 | "noMisleadingInstantiator": "error",
182 | "noMisrefactoredShorthandAssign": "off",
183 | "noPrototypeBuiltins": "error",
184 | "noRedeclare": "error",
185 | "noRedundantUseStrict": "error",
186 | "noSelfCompare": "off",
187 | "noShadowRestrictedNames": "error",
188 | "noSparseArray": "off",
189 | "noUnsafeDeclarationMerging": "error",
190 | "noUnsafeNegation": "error",
191 | "useDefaultSwitchClauseLast": "error",
192 | "useGetterReturn": "error",
193 | "useIsArray": "error",
194 | "useNamespaceKeyword": "error",
195 | "useValidTypeof": "error"
196 | },
197 | "nursery": {
198 | "noDuplicateJsonKeys": "off",
199 | "noEmptyBlockStatements": "error",
200 | "noEmptyTypeParameters": "error",
201 | "noGlobalEval": "off",
202 | "noGlobalAssign": "error",
203 | "noInvalidUseBeforeDeclaration": "error",
204 | "noMisleadingCharacterClass": "error",
205 | "noNodejsModules": "off",
206 | "noThenProperty": "warn",
207 | "noUnusedImports": "error",
208 | "noUnusedPrivateClassMembers": "error",
209 | "noUselessLoneBlockStatements": "error",
210 | "noUselessTernary": "error",
211 | "useAwait": "error",
212 | "useConsistentArrayType": "error",
213 | "useExportType": "error",
214 | "useFilenamingConvention": "off",
215 | "useForOf": "warn",
216 | "useGroupedTypeImport": "error",
217 | "useImportRestrictions": "off",
218 | "useImportType": "error",
219 | "useNodejsImportProtocol": "warn",
220 | "useNumberNamespace": "error",
221 | "useShorthandFunctionType": "warn"
222 | }
223 | }
224 | },
225 | "formatter": {
226 | "enabled": true,
227 | "ignore": ["node_modules/**/*"],
228 | "formatWithErrors": false,
229 | "indentWidth": 2,
230 | "indentStyle": "space",
231 | "lineEnding": "lf",
232 | "lineWidth": 80
233 | },
234 | "organizeImports": {
235 | "enabled": true,
236 | "ignore": ["node_modules/**/*"]
237 | },
238 | "javascript": {
239 | "formatter": {
240 | "enabled": true,
241 | "arrowParentheses": "asNeeded",
242 | "bracketSameLine": false,
243 | "bracketSpacing": true,
244 | "indentWidth": 2,
245 | "indentStyle": "space",
246 | "jsxQuoteStyle": "double",
247 | "lineEnding": "lf",
248 | "lineWidth": 80,
249 | "quoteProperties": "asNeeded",
250 | "quoteStyle": "single",
251 | "semicolons": "always",
252 | "trailingComma": "all"
253 | },
254 | "globals": [],
255 | "parser": {
256 | "unsafeParameterDecoratorsEnabled": true
257 | }
258 | },
259 | "json": {
260 | "formatter": {
261 | "enabled": true,
262 | "indentWidth": 2,
263 | "indentStyle": "space",
264 | "lineEnding": "lf",
265 | "lineWidth": 80
266 | },
267 | "parser": {
268 | "allowComments": false,
269 | "allowTrailingCommas": false
270 | }
271 | }
272 | }
273 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # thaler
2 |
3 | > Isomorphic server-side functions
4 |
5 | [](https://www.npmjs.com/package/thaler) [](https://github.com/airbnb/javascript)
6 |
7 | ## Install
8 |
9 | ```bash
10 | npm i thaler
11 | ```
12 |
13 | ```bash
14 | yarn add thaler
15 | ```
16 |
17 | ```bash
18 | pnpm add thaler
19 | ```
20 |
21 | ## What?
22 |
23 | `thaler` allows you to produce isomorphic functions that only runs on the server-side. This is usually ideal if you want to do server-side operations (e.g. database, files, etc.) on the client-side but without adding more abstractions such as defining extra REST endpoints, creating client-side utilities to communicate with the exact endpoint etc.
24 |
25 | Another biggest benefit of this is that, not only it is great for isomorphic fullstack apps (i.e. metaframeworks like NextJS, SolidStart, etc.), if you're using TypeScript, type inference is also consistent too, so no need for extra work to manually wire-in types for both server and client.
26 |
27 | ## Examples
28 |
29 | - [Astro](https://codesandbox.io/s/github/LXSMNSYC/thaler/tree/main/examples/astro)
30 | - [SvelteKit](https://codesandbox.io/s/github/LXSMNSYC/thaler/tree/main/examples/sveltekit)
31 | - [SolidStart](https://codesandbox.io/s/github/LXSMNSYC/thaler/tree/main/examples/solidstart)
32 |
33 | ## Functions
34 |
35 | ### `server$`
36 |
37 | `server$` is the simplest of the `thaler` functions, it receives a callback for processing server [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and returns a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
38 |
39 | The returned function can then accept request options (which is the second parameter for the `Request` object), you can also check out [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch)
40 |
41 | ```js
42 | import { server$ } from 'thaler';
43 |
44 | const getMessage = server$(async (request) => {
45 | const { greeting, receiver } = await request.json();
46 |
47 | return new Response(`${greeting}, ${receiver}!`, {
48 | status: 200,
49 | });
50 | });
51 |
52 | // Usage
53 | const response = await getMessage({
54 | method: 'POST',
55 | body: JSON.stringify({
56 | greeting: 'Hello',
57 | receiver: 'World',
58 | }),
59 | });
60 |
61 | console.log(await response.text()); // Hello, World!
62 | ```
63 |
64 | ### `get$`
65 |
66 | Similar to `server$` except that it can receive an object that can be converted into query params. The object can have a string or an array of strings as its values.
67 |
68 | Only `get$` can accept search parameters and uses the `GET` method, which makes it great for creating server-side logic that utilizes caching.
69 |
70 | ```js
71 | import { get$ } from 'thaler';
72 |
73 | const getMessage = get$(async ({ greeting, receiver }) => {
74 | return new Response(`${greeting}, ${receiver}!`, {
75 | status: 200,
76 | });
77 | });
78 |
79 | // Usage
80 | const response = await getMessage({
81 | greeting: 'Hello',
82 | receiver: 'World',
83 | });
84 |
85 | console.log(await response.text()); // Hello, World!
86 | ```
87 |
88 | You can also pass some request configuration (same as `server$`) as the second parameter for the function, however `get$` cannot have `method` or `body`. The callback in `get$` can also receive the `Request` instance as the second parameter.
89 |
90 | ```js
91 | import { get$ } from 'thaler';
92 |
93 | const getUser = get$((search, { request }) => {
94 | // do stuff
95 | });
96 |
97 | const user = await getUser(search, {
98 | headers: {
99 | // do some header stuff
100 | },
101 | });
102 | ```
103 |
104 | ### `post$`
105 |
106 | If `get$` is for `GET`, `post$` is for `POST`. Instead of query parameters, the object it receives is converted into form data, so the object can accept not only a string or an array of strings, but also a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob), a [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File), or an array of either of those types.
107 |
108 | Only `post$` can accept form data and uses the `POST` method, which makes it great for creating server-side logic when building forms.
109 |
110 | ```js
111 | import { post$ } from 'thaler';
112 |
113 | const addMessage = post$(async ({ greeting, receiver }) => {
114 | await db.messages.insert({ greeting, receiver });
115 | return new Response(null, {
116 | status: 200,
117 | });
118 | });
119 |
120 | // Usage
121 | await addMessage({
122 | greeting: 'Hello',
123 | receiver: 'World',
124 | });
125 | ```
126 |
127 | You can also pass some request configuration (same as `server$`) as the second parameter for the function, however `post$` cannot have `method` or `body`. The callback in `post$` can also receive the `Request` instance as the second parameter.
128 |
129 | ```js
130 | import { post$ } from 'thaler';
131 |
132 | const addMessage = post$((formData, { request }) => {
133 | // do stuff
134 | });
135 |
136 | await addMessage(formData, {
137 | headers: {
138 | // do some header stuff
139 | },
140 | });
141 | ```
142 |
143 | ### `fn$` and `pure$`
144 |
145 | Unlike `get$` and `post$`, `fn$` and `pure$` uses a superior form of serialization, so that not only it supports valid JSON values, it supports [an extended range of JS values](https://github.com/lxsmnsyc/seroval#supports).
146 |
147 | ```js
148 | import { fn$ } from 'thaler';
149 |
150 | const addUsers = fn$(async (users) => {
151 | const db = await import('./db');
152 | return Promise.all(users.map((user) => db.users.insert(user)));
153 | });
154 |
155 | await addUsers([
156 | { name: 'John Doe', email: 'john.doe@johndoe.com' },
157 | { name: 'Jane Doe', email: 'jane.doe@janedoe.com' },
158 | ]);
159 | ```
160 |
161 | You can also pass some request configuration (same as `server$`) as the second parameter for the function, however `fn$` cannot have `method` or `body`. The callback in `fn$` can also receive the `Request` instance as the second parameter.
162 |
163 | ```js
164 | import { fn$ } from 'thaler';
165 |
166 | const addMessage = fn$((data, { request }) => {
167 | // do stuff
168 | });
169 |
170 | await addMessage(data, {
171 | headers: {
172 | // do some header stuff
173 | },
174 | });
175 | ```
176 |
177 | ### `loader$` and `action$`
178 |
179 | `loader$` and `action$` is like both `get$` and `post$` in the exception that `loader$` and `action$` can return any serializable value instead of `Response`, much like `fn$` and `pure$`
180 |
181 | ```js
182 | import { action$, loader$ } from 'thaler';
183 |
184 | const addMessage = action$(async ({ greeting, receiver }) => {
185 | await db.messages.insert({ greeting, receiver });
186 | });
187 |
188 | const getMessage = loader$(({ id }) => (
189 | db.messages.select(id)
190 | ));
191 | ```
192 |
193 | ## Closure Extraction
194 |
195 | Other functions can capture server-side scope but unlike the other functions (including `pure$`), `fn$` has a special behavior: it can capture the client-side closure of where the function is declared on the client, serialize the captured closure and send it to the server.
196 |
197 | ```js
198 | import { fn$ } from 'thaler';
199 |
200 | const prefix = 'Message:';
201 |
202 | const getMessage = fn$(({ greeting, receiver }) => {
203 | // `prefix` is captured and sent to the server
204 | return `${prefix} "${greeting}, ${receiver}!"`;
205 | });
206 |
207 | console.log(await getMessage({ greeting: 'Hello', receiver: 'World' })); // Message: "Hello, World!"
208 | ```
209 |
210 | > **Note**
211 | > `fn$` can only capture local scope, and not global scope. `fn$` will ignore top-level scopes.
212 |
213 | > **Warning**
214 | > Be careful on capturing scopes, as the captured variables must only be the values that can be serialized by `fn$`. If you're using a value that can't be serialized inside the callback that is declared outside, it cannot be captured by `fn$` and will lead to runtime errors.
215 |
216 | ## Modifying `Response`
217 |
218 | `fn$`, `pure$`, `loader$` and `action$` doesn't return `Response` unlike `server$`, `get$` and `post$`, so there's no way to directly provide more `Response` information like headers.
219 |
220 | As a workaround, these functions receive a `response` object alongside `request`.
221 |
222 | ```js
223 | import { loader$ } from 'thaler';
224 |
225 | const getMessage = loader$(({ greeting, receiver }, { response }) => {
226 | response.headers.set('Cache-Control', 'max-age=86400');
227 | return `"${greeting}, ${receiver}!"`;
228 | });
229 | ```
230 |
231 | ## Server Handler
232 |
233 | To manage the server functions, `thaler/server` provides a function call `handleRequest`. This manages all the incoming client requests, which includes matching and running their respective server-side functions.
234 |
235 | ```js
236 | import { handleRequest } from 'thaler/server';
237 |
238 | const request = await handleRequest(request);
239 | if (request) {
240 | // Request was matched
241 | return request;
242 | }
243 | // Do other stuff
244 | ```
245 |
246 | Your server runtime must have the following Web API:
247 |
248 | - [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
249 | - [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
250 | - [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
251 | - [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File)
252 | - [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
253 | - [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
254 |
255 | Some polyfill recommendations:
256 |
257 | - [`node-fetch`](https://www.npmjs.com/package/node-fetch)
258 | - [`node-fetch-native`](https://github.com/unjs/node-fetch-native)
259 | - [`@remix-run/web-fetch`](https://github.com/remix-run/web-std-io/tree/main/packages/fetch)
260 |
261 | ## Intercepting Client Requests
262 |
263 | `thaler/client` provides `interceptRequest` to intercept/transform outgoing requests made by the functions. This is useful for adding request fields like headers.
264 |
265 | ```js
266 | import { interceptRequest } from 'thaler/client';
267 |
268 | interceptRequest((request) => {
269 | return new Request(request, {
270 | headers: {
271 | 'Authorization': 'Bearer ',
272 | },
273 | });
274 | });
275 |
276 | ```
277 |
278 | ## Custom Server Functions
279 |
280 | Thaler allows you to define your own server functions. Custom server functions must call one of thaler's internally defined server functions (e.g. `$$server` from `thaler/server` and `thaler/client`) and it has to be defined through the `functions` config and has the following interface:
281 |
282 | ```js
283 | // This is based on the unplugin integration
284 | thaler.vite({
285 | functions: [
286 | {
287 | // Name of the function
288 | name: 'server$',
289 | // Boolean check if the function needs to perform
290 | // closure extraction
291 | scoping: false,
292 | // Target identifier (to be compiled)
293 | target: {
294 | // Name of the identifier
295 | name: 'server$',
296 | // Where it is imported
297 | source: 'thaler',
298 | // Kind of import (named or default)
299 | kind: 'named',
300 | },
301 | // Compiled function for the client
302 | client: {
303 | // Compiled function identifier
304 | name: '$$server',
305 | // Where it is imported
306 | source: 'thaler/client',
307 | // Kind of import
308 | kind: 'named',
309 | },
310 | // Compiled function for the server
311 | server: {
312 | // Compiled function identifier
313 | name: '$$server',
314 | // Where it is imported
315 | source: 'thaler/server',
316 | // Kind of import
317 | kind: 'named',
318 | },
319 | }
320 | ],
321 | });
322 | ```
323 |
324 | ## `thaler/utils`
325 |
326 | ### `json(data, responseInit)`
327 |
328 | A shortcut function to create a `Response` object with JSON body.
329 |
330 | ### `text(data, responseInit)`
331 |
332 | A shortcut function to create a `Response` object with text body.
333 |
334 | ### `debounce(handler, options)`
335 |
336 | Creates a debounced version of the async handler. A debounced handler will defer its function call until no calls have been made within the given timeframe.
337 |
338 | Options:
339 |
340 | - `key`: Required. A function that produces a unique string based on the arguments. This is used to map the function call to its timer.
341 | - `timeout`: How long (in milliseconds) before a debounce call goes through. Defaults to `250`.
342 |
343 | ### `throttle(handler, options)`
344 |
345 | Creates a throttled version of the async handler. A throttled handler calls once for every given timeframe.
346 |
347 | Options:
348 |
349 | - `key`: Required. A function that produces a unique string based on the arguments. This is used to map the function call to its timer.
350 |
351 | ### `retry(handler, options)`
352 |
353 | Retries the `handler` when it throws an error until it resolves or the max retry count has been reached (in which case it will throw the final error). `retry` utilizes an [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) process to gradually slow down the retry intervals.
354 |
355 | - `interval`: The maximum interval for the exponential backoff. Initial interval starts at `10` ms and doubles every retry, up to the defined maximum interval. The default maximum interval is `5000` ms.
356 | - `count`: The maximum number of retries. Default is `10`.
357 |
358 | ### `timeout(handler, ms)`
359 |
360 | Attaches a timeout to the `handler`, that will throw if the `handler` fails to resolve before the given time.
361 |
362 | ## Integrations
363 |
364 | - [Vite](https://github.com/lxsmnsyc/thaler/tree/main/packages/vite)
365 |
366 | ## Inspirations
367 |
368 | - [Qwik](https://qwik.builder.io/)
369 | - [`loader$`](https://qwik.builder.io/qwikcity/loader/)
370 | - [`action$`](https://qwik.builder.io/qwikcity/action/)
371 | - [SolidStart](https://start.solidjs.com/getting-started/what-is-solidstart)
372 | - [`server$`](https://start.solidjs.com/api/server)
373 |
374 | ## Sponsors
375 |
376 | 
377 |
378 | ## License
379 |
380 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc)
381 |
--------------------------------------------------------------------------------
/packages/thaler/README.md:
--------------------------------------------------------------------------------
1 | # thaler
2 |
3 | > Isomorphic server-side functions
4 |
5 | [](https://www.npmjs.com/package/thaler) [](https://github.com/airbnb/javascript)
6 |
7 | ## Install
8 |
9 | ```bash
10 | npm i thaler
11 | ```
12 |
13 | ```bash
14 | yarn add thaler
15 | ```
16 |
17 | ```bash
18 | pnpm add thaler
19 | ```
20 |
21 | ## What?
22 |
23 | `thaler` allows you to produce isomorphic functions that only runs on the server-side. This is usually ideal if you want to do server-side operations (e.g. database, files, etc.) on the client-side but without adding more abstractions such as defining extra REST endpoints, creating client-side utilities to communicate with the exact endpoint etc.
24 |
25 | Another biggest benefit of this is that, not only it is great for isomorphic fullstack apps (i.e. metaframeworks like NextJS, SolidStart, etc.), if you're using TypeScript, type inference is also consistent too, so no need for extra work to manually wire-in types for both server and client.
26 |
27 | ## Examples
28 |
29 | - [Astro](https://codesandbox.io/s/github/LXSMNSYC/thaler/tree/main/examples/astro)
30 | - [SvelteKit](https://codesandbox.io/s/github/LXSMNSYC/thaler/tree/main/examples/sveltekit)
31 | - [SolidStart](https://codesandbox.io/s/github/LXSMNSYC/thaler/tree/main/examples/solidstart)
32 |
33 | ## Functions
34 |
35 | ### `server$`
36 |
37 | `server$` is the simplest of the `thaler` functions, it receives a callback for processing server [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and returns a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
38 |
39 | The returned function can then accept request options (which is the second parameter for the `Request` object), you can also check out [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch)
40 |
41 | ```js
42 | import { server$ } from 'thaler';
43 |
44 | const getMessage = server$(async (request) => {
45 | const { greeting, receiver } = await request.json();
46 |
47 | return new Response(`${greeting}, ${receiver}!`, {
48 | status: 200,
49 | });
50 | });
51 |
52 | // Usage
53 | const response = await getMessage({
54 | method: 'POST',
55 | body: JSON.stringify({
56 | greeting: 'Hello',
57 | receiver: 'World',
58 | }),
59 | });
60 |
61 | console.log(await response.text()); // Hello, World!
62 | ```
63 |
64 | ### `get$`
65 |
66 | Similar to `server$` except that it can receive an object that can be converted into query params. The object can have a string or an array of strings as its values.
67 |
68 | Only `get$` can accept search parameters and uses the `GET` method, which makes it great for creating server-side logic that utilizes caching.
69 |
70 | ```js
71 | import { get$ } from 'thaler';
72 |
73 | const getMessage = get$(async ({ greeting, receiver }) => {
74 | return new Response(`${greeting}, ${receiver}!`, {
75 | status: 200,
76 | });
77 | });
78 |
79 | // Usage
80 | const response = await getMessage({
81 | greeting: 'Hello',
82 | receiver: 'World',
83 | });
84 |
85 | console.log(await response.text()); // Hello, World!
86 | ```
87 |
88 | You can also pass some request configuration (same as `server$`) as the second parameter for the function, however `get$` cannot have `method` or `body`. The callback in `get$` can also receive the `Request` instance as the second parameter.
89 |
90 | ```js
91 | import { get$ } from 'thaler';
92 |
93 | const getUser = get$((search, { request }) => {
94 | // do stuff
95 | });
96 |
97 | const user = await getUser(search, {
98 | headers: {
99 | // do some header stuff
100 | },
101 | });
102 | ```
103 |
104 | ### `post$`
105 |
106 | If `get$` is for `GET`, `post$` is for `POST`. Instead of query parameters, the object it receives is converted into form data, so the object can accept not only a string or an array of strings, but also a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob), a [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File), or an array of either of those types.
107 |
108 | Only `post$` can accept form data and uses the `POST` method, which makes it great for creating server-side logic when building forms.
109 |
110 | ```js
111 | import { post$ } from 'thaler';
112 |
113 | const addMessage = post$(async ({ greeting, receiver }) => {
114 | await db.messages.insert({ greeting, receiver });
115 | return new Response(null, {
116 | status: 200,
117 | });
118 | });
119 |
120 | // Usage
121 | await addMessage({
122 | greeting: 'Hello',
123 | receiver: 'World',
124 | });
125 | ```
126 |
127 | You can also pass some request configuration (same as `server$`) as the second parameter for the function, however `post$` cannot have `method` or `body`. The callback in `post$` can also receive the `Request` instance as the second parameter.
128 |
129 | ```js
130 | import { post$ } from 'thaler';
131 |
132 | const addMessage = post$((formData, { request }) => {
133 | // do stuff
134 | });
135 |
136 | await addMessage(formData, {
137 | headers: {
138 | // do some header stuff
139 | },
140 | });
141 | ```
142 |
143 | ### `fn$` and `pure$`
144 |
145 | Unlike `get$` and `post$`, `fn$` and `pure$` uses a superior form of serialization, so that not only it supports valid JSON values, it supports [an extended range of JS values](https://github.com/lxsmnsyc/seroval#supports).
146 |
147 | ```js
148 | import { fn$ } from 'thaler';
149 |
150 | const addUsers = fn$(async (users) => {
151 | const db = await import('./db');
152 | return Promise.all(users.map((user) => db.users.insert(user)));
153 | });
154 |
155 | await addUsers([
156 | { name: 'John Doe', email: 'john.doe@johndoe.com' },
157 | { name: 'Jane Doe', email: 'jane.doe@janedoe.com' },
158 | ]);
159 | ```
160 |
161 | You can also pass some request configuration (same as `server$`) as the second parameter for the function, however `fn$` cannot have `method` or `body`. The callback in `fn$` can also receive the `Request` instance as the second parameter.
162 |
163 | ```js
164 | import { fn$ } from 'thaler';
165 |
166 | const addMessage = fn$((data, { request }) => {
167 | // do stuff
168 | });
169 |
170 | await addMessage(data, {
171 | headers: {
172 | // do some header stuff
173 | },
174 | });
175 | ```
176 |
177 | ### `loader$` and `action$`
178 |
179 | `loader$` and `action$` is like both `get$` and `post$` in the exception that `loader$` and `action$` can return any serializable value instead of `Response`, much like `fn$` and `pure$`
180 |
181 | ```js
182 | import { action$, loader$ } from 'thaler';
183 |
184 | const addMessage = action$(async ({ greeting, receiver }) => {
185 | await db.messages.insert({ greeting, receiver });
186 | });
187 |
188 | const getMessage = loader$(({ id }) => (
189 | db.messages.select(id)
190 | ));
191 | ```
192 |
193 | ## Closure Extraction
194 |
195 | Other functions can capture server-side scope but unlike the other functions (including `pure$`), `fn$` has a special behavior: it can capture the client-side closure of where the function is declared on the client, serialize the captured closure and send it to the server.
196 |
197 | ```js
198 | import { fn$ } from 'thaler';
199 |
200 | const prefix = 'Message:';
201 |
202 | const getMessage = fn$(({ greeting, receiver }) => {
203 | // `prefix` is captured and sent to the server
204 | return `${prefix} "${greeting}, ${receiver}!"`;
205 | });
206 |
207 | console.log(await getMessage({ greeting: 'Hello', receiver: 'World' })); // Message: "Hello, World!"
208 | ```
209 |
210 | > **Note**
211 | > `fn$` can only capture local scope, and not global scope. `fn$` will ignore top-level scopes.
212 |
213 | > **Warning**
214 | > Be careful on capturing scopes, as the captured variables must only be the values that can be serialized by `fn$`. If you're using a value that can't be serialized inside the callback that is declared outside, it cannot be captured by `fn$` and will lead to runtime errors.
215 |
216 | ## Modifying `Response`
217 |
218 | `fn$`, `pure$`, `loader$` and `action$` doesn't return `Response` unlike `server$`, `get$` and `post$`, so there's no way to directly provide more `Response` information like headers.
219 |
220 | As a workaround, these functions receive a `response` object alongside `request`.
221 |
222 | ```js
223 | import { loader$ } from 'thaler';
224 |
225 | const getMessage = loader$(({ greeting, receiver }, { response }) => {
226 | response.headers.set('Cache-Control', 'max-age=86400');
227 | return `"${greeting}, ${receiver}!"`;
228 | });
229 | ```
230 |
231 | ## Server Handler
232 |
233 | To manage the server functions, `thaler/server` provides a function call `handleRequest`. This manages all the incoming client requests, which includes matching and running their respective server-side functions.
234 |
235 | ```js
236 | import { handleRequest } from 'thaler/server';
237 |
238 | const request = await handleRequest(request);
239 | if (request) {
240 | // Request was matched
241 | return request;
242 | }
243 | // Do other stuff
244 | ```
245 |
246 | Your server runtime must have the following Web API:
247 |
248 | - [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
249 | - [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
250 | - [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
251 | - [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File)
252 | - [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
253 | - [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
254 |
255 | Some polyfill recommendations:
256 |
257 | - [`node-fetch`](https://www.npmjs.com/package/node-fetch)
258 | - [`node-fetch-native`](https://github.com/unjs/node-fetch-native)
259 | - [`@remix-run/web-fetch`](https://github.com/remix-run/web-std-io/tree/main/packages/fetch)
260 |
261 | ## Intercepting Client Requests
262 |
263 | `thaler/client` provides `interceptRequest` to intercept/transform outgoing requests made by the functions. This is useful for adding request fields like headers.
264 |
265 | ```js
266 | import { interceptRequest } from 'thaler/client';
267 |
268 | interceptRequest((request) => {
269 | return new Request(request, {
270 | headers: {
271 | 'Authorization': 'Bearer ',
272 | },
273 | });
274 | });
275 |
276 | ```
277 |
278 | ## Custom Server Functions
279 |
280 | Thaler allows you to define your own server functions. Custom server functions must call one of thaler's internally defined server functions (e.g. `$$server` from `thaler/server` and `thaler/client`) and it has to be defined through the `functions` config and has the following interface:
281 |
282 | ```js
283 | // This is based on the unplugin integration
284 | thaler.vite({
285 | functions: [
286 | {
287 | // Name of the function
288 | name: 'server$',
289 | // Boolean check if the function needs to perform
290 | // closure extraction
291 | scoping: false,
292 | // Target identifier (to be compiled)
293 | target: {
294 | // Name of the identifier
295 | name: 'server$',
296 | // Where it is imported
297 | source: 'thaler',
298 | // Kind of import (named or default)
299 | kind: 'named',
300 | },
301 | // Compiled function for the client
302 | client: {
303 | // Compiled function identifier
304 | name: '$$server',
305 | // Where it is imported
306 | source: 'thaler/client',
307 | // Kind of import
308 | kind: 'named',
309 | },
310 | // Compiled function for the server
311 | server: {
312 | // Compiled function identifier
313 | name: '$$server',
314 | // Where it is imported
315 | source: 'thaler/server',
316 | // Kind of import
317 | kind: 'named',
318 | },
319 | }
320 | ],
321 | });
322 | ```
323 |
324 | ## `thaler/utils`
325 |
326 | ### `json(data, responseInit)`
327 |
328 | A shortcut function to create a `Response` object with JSON body.
329 |
330 | ### `text(data, responseInit)`
331 |
332 | A shortcut function to create a `Response` object with text body.
333 |
334 | ### `debounce(handler, options)`
335 |
336 | Creates a debounced version of the async handler. A debounced handler will defer its function call until no calls have been made within the given timeframe.
337 |
338 | Options:
339 |
340 | - `key`: Required. A function that produces a unique string based on the arguments. This is used to map the function call to its timer.
341 | - `timeout`: How long (in milliseconds) before a debounce call goes through. Defaults to `250`.
342 |
343 | ### `throttle(handler, options)`
344 |
345 | Creates a throttled version of the async handler. A throttled handler calls once for every given timeframe.
346 |
347 | Options:
348 |
349 | - `key`: Required. A function that produces a unique string based on the arguments. This is used to map the function call to its timer.
350 |
351 | ### `retry(handler, options)`
352 |
353 | Retries the `handler` when it throws an error until it resolves or the max retry count has been reached (in which case it will throw the final error). `retry` utilizes an [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) process to gradually slow down the retry intervals.
354 |
355 | - `interval`: The maximum interval for the exponential backoff. Initial interval starts at `10` ms and doubles every retry, up to the defined maximum interval. The default maximum interval is `5000` ms.
356 | - `count`: The maximum number of retries. Default is `10`.
357 |
358 | ### `timeout(handler, ms)`
359 |
360 | Attaches a timeout to the `handler`, that will throw if the `handler` fails to resolve before the given time.
361 |
362 | ## Integrations
363 |
364 | - [Vite](https://github.com/lxsmnsyc/thaler/tree/main/packages/vite)
365 |
366 | ## Inspirations
367 |
368 | - [Qwik](https://qwik.builder.io/)
369 | - [`loader$`](https://qwik.builder.io/qwikcity/loader/)
370 | - [`action$`](https://qwik.builder.io/qwikcity/action/)
371 | - [SolidStart](https://start.solidjs.com/getting-started/what-is-solidstart)
372 | - [`server$`](https://start.solidjs.com/api/server)
373 |
374 | ## Sponsors
375 |
376 | 
377 |
378 | ## License
379 |
380 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc)
381 |
--------------------------------------------------------------------------------
/packages/thaler/compiler/plugin.ts:
--------------------------------------------------------------------------------
1 | import type * as babel from '@babel/core';
2 | import { addDefault, addNamed } from '@babel/helper-module-imports';
3 | import * as t from '@babel/types';
4 | import { parse as parsePath } from 'node:path';
5 | import { getImportSpecifierName } from './checks';
6 | import getForeignBindings from './get-foreign-bindings';
7 | import unwrapNode from './unwrap-node';
8 | import xxHash32 from './xxhash32';
9 | import type { APIRegistration, ImportDefinition } from './imports';
10 | import { API } from './imports';
11 | import { unexpectedArgumentLength, unexpectedType } from './errors';
12 | import unwrapPath from './unwrap-path';
13 |
14 | interface InternalRegistration {
15 | clone: ImportDefinition;
16 | scope: ImportDefinition;
17 | ref: ImportDefinition;
18 | }
19 |
20 | const SERVER_IMPORTS: InternalRegistration = {
21 | clone: {
22 | kind: 'named',
23 | source: 'thaler/server',
24 | name: '$$clone',
25 | },
26 | scope: {
27 | kind: 'named',
28 | source: 'thaler/server',
29 | name: '$$scope',
30 | },
31 | ref: {
32 | kind: 'named',
33 | source: 'thaler/server',
34 | name: '$$ref',
35 | },
36 | };
37 |
38 | const CLIENT_IMPORTS: InternalRegistration = {
39 | clone: {
40 | kind: 'named',
41 | source: 'thaler/client',
42 | name: '$$clone',
43 | },
44 | scope: {
45 | kind: 'named',
46 | source: 'thaler/client',
47 | name: '$$scope',
48 | },
49 | ref: {
50 | kind: 'named',
51 | source: 'thaler/client',
52 | name: '$$ref',
53 | },
54 | };
55 | export interface PluginOptions {
56 | source: string;
57 | prefix?: string;
58 | mode: 'server' | 'client';
59 | env?: 'development' | 'production';
60 | functions?: APIRegistration[];
61 | }
62 |
63 | interface StateContext extends babel.PluginPass {
64 | functions: APIRegistration[];
65 | imports: Map;
66 | registrations: {
67 | identifiers: Map;
68 | namespaces: Map;
69 | };
70 | refRegistry: {
71 | identifiers: Set;
72 | namespaces: Set;
73 | };
74 | count: number;
75 | prefix: string;
76 | opts: PluginOptions;
77 | }
78 |
79 | function getImportIdentifier(
80 | state: StateContext,
81 | path: babel.NodePath,
82 | registration: ImportDefinition,
83 | ): t.Identifier {
84 | const name = registration.kind === 'named' ? registration.name : 'default';
85 | const target = `${registration.source}[${name}]`;
86 | const current = state.imports.get(target);
87 | if (current) {
88 | return current;
89 | }
90 | const newID =
91 | registration.kind === 'named'
92 | ? addNamed(path, registration.name, registration.source)
93 | : addDefault(path, registration.source);
94 | state.imports.set(target, newID);
95 | return newID;
96 | }
97 |
98 | function registerFunctionSpecifier(
99 | ctx: StateContext,
100 | path: babel.NodePath,
101 | registration: APIRegistration,
102 | ): void {
103 | for (let i = 0, len = path.node.specifiers.length; i < len; i++) {
104 | const specifier = path.node.specifiers[i];
105 | switch (specifier.type) {
106 | case 'ImportDefaultSpecifier': {
107 | if (registration.target.kind === 'default') {
108 | ctx.registrations.identifiers.set(specifier.local, registration);
109 | }
110 | break;
111 | }
112 | case 'ImportNamespaceSpecifier': {
113 | let current = ctx.registrations.namespaces.get(specifier.local);
114 | if (!current) {
115 | current = [];
116 | }
117 | current.push(registration);
118 | ctx.registrations.namespaces.set(specifier.local, current);
119 | break;
120 | }
121 | case 'ImportSpecifier': {
122 | const key = getImportSpecifierName(specifier);
123 | if (
124 | (registration.target.kind === 'named' &&
125 | key === registration.target.name) ||
126 | (registration.target.kind === 'default' && key === 'default')
127 | ) {
128 | ctx.registrations.identifiers.set(specifier.local, registration);
129 | }
130 | break;
131 | }
132 | default:
133 | break;
134 | }
135 | }
136 | }
137 |
138 | function registerRefSpecifier(
139 | ctx: StateContext,
140 | path: babel.NodePath,
141 | ): void {
142 | for (let i = 0, len = path.node.specifiers.length; i < len; i++) {
143 | const specifier = path.node.specifiers[i];
144 | switch (specifier.type) {
145 | case 'ImportNamespaceSpecifier': {
146 | ctx.refRegistry.namespaces.add(specifier.local);
147 | break;
148 | }
149 | case 'ImportSpecifier': {
150 | if (getImportSpecifierName(specifier) === 'ref$') {
151 | ctx.refRegistry.identifiers.add(specifier.local);
152 | }
153 | break;
154 | }
155 | default:
156 | break;
157 | }
158 | }
159 | }
160 |
161 | function extractImportIdentifiers(
162 | ctx: StateContext,
163 | path: babel.NodePath,
164 | ): void {
165 | const mod = path.node.source.value;
166 |
167 | for (let i = 0, len = ctx.functions.length; i < len; i++) {
168 | const func = ctx.functions[i];
169 | if (mod === func.target.source) {
170 | registerFunctionSpecifier(ctx, path, func);
171 | }
172 | }
173 |
174 | if (mod === 'thaler') {
175 | registerRefSpecifier(ctx, path);
176 | }
177 | }
178 |
179 | function getRootStatementPath(path: babel.NodePath): babel.NodePath {
180 | let current = path.parentPath;
181 | while (current) {
182 | const next = current.parentPath;
183 | if (next && t.isProgram(next.node)) {
184 | return current;
185 | }
186 | current = next;
187 | }
188 | return path;
189 | }
190 |
191 | function getDescriptiveName(path: babel.NodePath, defaultName: string): string {
192 | let current: babel.NodePath | null = path;
193 | while (current) {
194 | switch (current.node.type) {
195 | case 'FunctionDeclaration':
196 | case 'FunctionExpression': {
197 | if (current.node.id) {
198 | return current.node.id.name;
199 | }
200 | break;
201 | }
202 | case 'VariableDeclarator': {
203 | if (current.node.id.type === 'Identifier') {
204 | return current.node.id.name;
205 | }
206 | break;
207 | }
208 | case 'ClassPrivateMethod':
209 | case 'ClassMethod':
210 | case 'ObjectMethod': {
211 | switch (current.node.key.type) {
212 | case 'Identifier':
213 | return current.node.key.name;
214 | case 'PrivateName':
215 | return current.node.key.id.name;
216 | default:
217 | break;
218 | }
219 | break;
220 | }
221 | default:
222 | break;
223 | }
224 | current = current.parentPath;
225 | }
226 | return defaultName;
227 | }
228 |
229 | function extractThalerFunction(
230 | path: babel.NodePath,
231 | ): babel.NodePath {
232 | const args = path.get('arguments');
233 | if (args.length === 0) {
234 | throw unexpectedArgumentLength(path, args.length, 1);
235 | }
236 | const arg = args[0];
237 | const argument = unwrapPath(arg, t.isFunction);
238 | if (
239 | argument &&
240 | (argument.isArrowFunctionExpression() || argument.isFunctionExpression())
241 | ) {
242 | return argument;
243 | }
244 | throw unexpectedType(
245 | arg,
246 | arg.node.type,
247 | 'ArrowFunctionExpression | FunctionExpression',
248 | );
249 | }
250 |
251 | function createThalerFunction(
252 | ctx: StateContext,
253 | path: babel.NodePath,
254 | registration: APIRegistration,
255 | ): void {
256 | const argument = extractThalerFunction(path);
257 | // Create an ID
258 | let id = `${ctx.prefix}${ctx.count}`;
259 | if (ctx.opts.env !== 'production') {
260 | id += `-${getDescriptiveName(argument, 'anonymous')}`;
261 | }
262 | ctx.count += 1;
263 | // Create the call expression
264 | const registerArgs: t.Expression[] = [t.stringLiteral(id)];
265 | if (ctx.opts.mode === 'server') {
266 | // Hoist the argument
267 | registerArgs.push(argument.node);
268 | }
269 |
270 | // Create registration call
271 | const registerID = path.scope.generateUidIdentifier(registration.name);
272 | const register = t.callExpression(
273 | getImportIdentifier(
274 | ctx,
275 | path,
276 | ctx.opts.mode === 'server' ? registration.server : registration.client,
277 | ),
278 | registerArgs,
279 | );
280 | // Locate root statement (the top-level statement)
281 | const rootStatement = getRootStatementPath(path);
282 | // Push the declaration
283 | rootStatement.insertBefore(
284 | t.variableDeclaration('const', [
285 | t.variableDeclarator(registerID, register),
286 | ]),
287 | );
288 | // Setup for clone call
289 | const cloneArgs: t.Expression[] = [registerID];
290 | // Collect bindings for scoping
291 | if (registration.scoping) {
292 | const scope = getForeignBindings(argument);
293 | cloneArgs.push(t.arrowFunctionExpression([], t.arrayExpression(scope)));
294 | if (scope.length) {
295 | // Add scoping to the arrow function
296 | if (ctx.opts.mode === 'server') {
297 | const statement = t.isStatement(argument.node.body)
298 | ? argument.node.body
299 | : t.blockStatement([t.returnStatement(argument.node.body)]);
300 | statement.body = [
301 | t.variableDeclaration('const', [
302 | t.variableDeclarator(
303 | t.arrayPattern(scope),
304 | t.callExpression(
305 | getImportIdentifier(
306 | ctx,
307 | path,
308 | ctx.opts.mode === 'server'
309 | ? SERVER_IMPORTS.scope
310 | : CLIENT_IMPORTS.scope,
311 | ),
312 | [],
313 | ),
314 | ),
315 | ]),
316 | ...statement.body,
317 | ];
318 |
319 | argument.node.body = statement;
320 | }
321 | }
322 | }
323 | // Replace with clone
324 | path.replaceWith(
325 | t.callExpression(
326 | getImportIdentifier(
327 | ctx,
328 | path,
329 | ctx.opts.mode === 'server'
330 | ? SERVER_IMPORTS.clone
331 | : CLIENT_IMPORTS.clone,
332 | ),
333 | cloneArgs,
334 | ),
335 | );
336 | }
337 |
338 | function createRefFunction(
339 | ctx: StateContext,
340 | path: babel.NodePath,
341 | ): void {
342 | // Create an ID
343 | const id = `${ctx.prefix}${ctx.count}`;
344 | ctx.count += 1;
345 | path.replaceWith(
346 | t.callExpression(
347 | getImportIdentifier(
348 | ctx,
349 | path,
350 | ctx.opts.mode === 'server' ? SERVER_IMPORTS.ref : CLIENT_IMPORTS.ref,
351 | ),
352 | [t.stringLiteral(id), ...path.node.arguments],
353 | ),
354 | );
355 | }
356 |
357 | function transformCall(
358 | ctx: StateContext,
359 | path: babel.NodePath,
360 | ): void {
361 | const trueID = unwrapNode(path.node.callee, t.isIdentifier);
362 | if (trueID) {
363 | const binding = path.scope.getBindingIdentifier(trueID.name);
364 | if (binding) {
365 | const registry = ctx.registrations.identifiers.get(binding);
366 | if (registry) {
367 | createThalerFunction(ctx, path, registry);
368 | }
369 | if (ctx.refRegistry.identifiers.has(binding)) {
370 | createRefFunction(ctx, path);
371 | }
372 | }
373 | }
374 | const trueMemberExpr = unwrapNode(path.node.callee, t.isMemberExpression);
375 | if (
376 | trueMemberExpr &&
377 | !trueMemberExpr.computed &&
378 | t.isIdentifier(trueMemberExpr.property)
379 | ) {
380 | const obj = unwrapNode(trueMemberExpr.object, t.isIdentifier);
381 | if (obj) {
382 | const binding = path.scope.getBindingIdentifier(obj.name);
383 | if (binding) {
384 | const propName = trueMemberExpr.property.name;
385 | const registrations = ctx.registrations.namespaces.get(binding);
386 | if (registrations) {
387 | for (let i = 0, len = registrations.length; i < len; i++) {
388 | const registration = registrations[i];
389 | if (registration && registration.name === propName) {
390 | createThalerFunction(ctx, path, registration);
391 | }
392 | }
393 | }
394 | if (ctx.refRegistry.namespaces.has(binding) && propName === 'ref$') {
395 | createRefFunction(ctx, path);
396 | }
397 | }
398 | }
399 | }
400 | }
401 |
402 | const DEFAULT_PREFIX = '__thaler';
403 |
404 | function getPrefix(ctx: StateContext): string {
405 | const prefix = ctx.opts.prefix == null ? DEFAULT_PREFIX : ctx.opts.prefix;
406 | let file = '';
407 | if (ctx.opts.source) {
408 | file = ctx.opts.source;
409 | } else if (ctx.filename) {
410 | file = ctx.filename;
411 | }
412 | const base = `/${prefix}/${xxHash32(file).toString(16)}-`;
413 | if (ctx.opts.env === 'production') {
414 | return base;
415 | }
416 | const parsed = parsePath(file);
417 | return `${base}${parsed.name}-`;
418 | }
419 |
420 | export default function thalerPlugin(): babel.PluginObj {
421 | return {
422 | name: 'thaler',
423 | pre(): void {
424 | this.functions = [...API];
425 | this.imports = new Map();
426 | this.registrations = {
427 | identifiers: new Map(),
428 | namespaces: new Map(),
429 | };
430 | this.refRegistry = {
431 | identifiers: new Set(),
432 | namespaces: new Set(),
433 | };
434 | this.count = 0;
435 | },
436 | visitor: {
437 | Program(programPath, ctx): void {
438 | ctx.prefix = getPrefix(ctx);
439 | ctx.functions = [...API, ...(ctx.opts.functions || [])];
440 | programPath.traverse({
441 | ImportDeclaration(path) {
442 | extractImportIdentifiers(ctx, path);
443 | },
444 | });
445 | },
446 | CallExpression(path, ctx): void {
447 | transformCall(ctx, path);
448 | },
449 | OptionalCallExpression(path, ctx): void {
450 | transformCall(ctx, path);
451 | },
452 | },
453 | };
454 | }
455 |
--------------------------------------------------------------------------------
/packages/thaler/server/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createReference,
3 | crossSerializeStream,
4 | toJSONAsync,
5 | getCrossReferenceHeader,
6 | } from 'seroval';
7 | import type {
8 | ThalerPostHandler,
9 | ThalerPostParam,
10 | ThalerFnHandler,
11 | ThalerFunctions,
12 | ThalerGetHandler,
13 | ThalerGetParam,
14 | ThalerPureHandler,
15 | ThalerServerHandler,
16 | ThalerActionHandler,
17 | ThalerLoaderHandler,
18 | ThalerResponseInit,
19 | ThalerFunctionTypes,
20 | } from '../shared/types';
21 | import type { FunctionBody } from '../shared/utils';
22 | import {
23 | XThalerID,
24 | XThalerInstance,
25 | XThalerRequestType,
26 | deserializeData,
27 | fromFormData,
28 | fromURLSearchParams,
29 | patchHeaders,
30 | serializeFunctionBody,
31 | toFormData,
32 | toURLSearchParams,
33 | } from '../shared/utils';
34 | import {
35 | CustomEventPlugin,
36 | DOMExceptionPlugin,
37 | EventPlugin,
38 | FormDataPlugin,
39 | HeadersPlugin,
40 | ReadableStreamPlugin,
41 | RequestPlugin,
42 | ResponsePlugin,
43 | URLSearchParamsPlugin,
44 | URLPlugin,
45 | } from 'seroval-plugins/web';
46 | import ThalerError from '../shared/error';
47 |
48 | type ServerHandlerRegistration = [
49 | type: 'server',
50 | id: string,
51 | callback: ThalerServerHandler,
52 | ];
53 | type GetHandlerRegistration = [
54 | type: 'get',
55 | id: string,
56 | callback: ThalerGetHandler
,
57 | ];
58 | type PostHandlerRegistration
= [
59 | type: 'post',
60 | id: string,
61 | callback: ThalerPostHandler
,
62 | ];
63 | type FunctionHandlerRegistration = [
64 | type: 'fn',
65 | id: string,
66 | callback: ThalerFnHandler,
67 | ];
68 | type PureHandlerRegistration = [
69 | type: 'pure',
70 | id: string,
71 | callback: ThalerPureHandler,
72 | ];
73 | type LoaderHandlerRegistration = [
74 | type: 'loader',
75 | id: string,
76 | callback: ThalerLoaderHandler
,
77 | ];
78 | type ActionHandlerRegistration
= [
79 | type: 'action',
80 | id: string,
81 | callback: ThalerActionHandler
,
82 | ];
83 |
84 | type HandlerRegistration =
85 | | ServerHandlerRegistration
86 | | GetHandlerRegistration
87 | | PostHandlerRegistration
88 | | FunctionHandlerRegistration
89 | | PureHandlerRegistration
90 | | LoaderHandlerRegistration
91 | | ActionHandlerRegistration;
92 |
93 | const REGISTRATIONS = new Map();
94 |
95 | export function $$server(
96 | id: string,
97 | callback: ThalerServerHandler,
98 | ): HandlerRegistration {
99 | const reg: ServerHandlerRegistration = ['server', id, callback];
100 | REGISTRATIONS.set(id, reg);
101 | return reg;
102 | }
103 | export function $$post(
104 | id: string,
105 | callback: ThalerPostHandler
,
106 | ): HandlerRegistration {
107 | const reg: PostHandlerRegistration
= ['post', id, callback];
108 | REGISTRATIONS.set(id, reg);
109 | return reg;
110 | }
111 | export function $$get
(
112 | id: string,
113 | callback: ThalerGetHandler
,
114 | ): HandlerRegistration {
115 | const reg: GetHandlerRegistration
= ['get', id, callback];
116 | REGISTRATIONS.set(id, reg);
117 | return reg;
118 | }
119 | export function $$fn(
120 | id: string,
121 | callback: ThalerFnHandler,
122 | ): HandlerRegistration {
123 | const reg: FunctionHandlerRegistration = ['fn', id, callback];
124 | REGISTRATIONS.set(id, reg);
125 | return reg;
126 | }
127 | export function $$pure(
128 | id: string,
129 | callback: ThalerPureHandler,
130 | ): HandlerRegistration {
131 | const reg: PureHandlerRegistration = ['pure', id, callback];
132 | REGISTRATIONS.set(id, reg);
133 | return reg;
134 | }
135 | export function $$loader(
136 | id: string,
137 | callback: ThalerLoaderHandler,
138 | ): HandlerRegistration {
139 | const reg: LoaderHandlerRegistration = ['loader', id, callback];
140 | REGISTRATIONS.set(id, reg);
141 | return reg;
142 | }
143 | export function $$action(
144 | id: string,
145 | callback: ThalerActionHandler,
146 | ): HandlerRegistration {
147 | const reg: ActionHandlerRegistration = ['action', id, callback];
148 | REGISTRATIONS.set(id, reg);
149 | return reg;
150 | }
151 |
152 | function createChunk(data: string): Uint8Array {
153 | const bytes = data.length;
154 | const baseHex = bytes.toString(16);
155 | const totalHex = '00000000'.substring(0, 8 - baseHex.length) + baseHex; // 32-bit
156 | return new TextEncoder().encode(`;0x${totalHex};${data}`);
157 | }
158 |
159 | function serializeToStream(instance: string, value: T): ReadableStream {
160 | return new ReadableStream({
161 | start(controller): void {
162 | crossSerializeStream(value, {
163 | scopeId: instance,
164 | plugins: [
165 | CustomEventPlugin,
166 | DOMExceptionPlugin,
167 | EventPlugin,
168 | FormDataPlugin,
169 | HeadersPlugin,
170 | ReadableStreamPlugin,
171 | RequestPlugin,
172 | ResponsePlugin,
173 | URLSearchParamsPlugin,
174 | URLPlugin,
175 | ],
176 | onSerialize(data, initial) {
177 | controller.enqueue(
178 | createChunk(
179 | initial ? `(${getCrossReferenceHeader(instance)},${data})` : data,
180 | ),
181 | );
182 | },
183 | onDone() {
184 | controller.close();
185 | },
186 | onError(error) {
187 | controller.error(error);
188 | },
189 | });
190 | },
191 | });
192 | }
193 |
194 | function createResponseInit(
195 | type: ThalerFunctionTypes,
196 | id: string,
197 | instance: string,
198 | ): ThalerResponseInit {
199 | return {
200 | headers: new Headers({
201 | 'Content-Type': 'text/javascript',
202 | [XThalerRequestType]: type,
203 | [XThalerInstance]: instance,
204 | [XThalerID]: id,
205 | }),
206 | status: 200,
207 | statusText: 'OK',
208 | };
209 | }
210 |
211 | function normalizeURL(id: string): URL {
212 | return new URL(id, 'http://localhost');
213 | }
214 |
215 | async function serverHandler(
216 | id: string,
217 | callback: ThalerServerHandler,
218 | init: RequestInit,
219 | ): Promise {
220 | patchHeaders('server', id, init);
221 | return await callback(new Request(normalizeURL(id), init));
222 | }
223 |
224 | async function postHandler(
225 | id: string,
226 | callback: ThalerPostHandler
,
227 | formData: P,
228 | init: RequestInit = {},
229 | ): Promise {
230 | patchHeaders('post', id, init);
231 | return await callback(formData, {
232 | request: new Request(normalizeURL(id), {
233 | ...init,
234 | method: 'POST',
235 | body: toFormData(formData),
236 | }),
237 | });
238 | }
239 |
240 | async function getHandler(
241 | id: string,
242 | callback: ThalerGetHandler
,
243 | search: P,
244 | init: RequestInit = {},
245 | ): Promise {
246 | patchHeaders('get', id, init);
247 | return await callback(search, {
248 | request: new Request(
249 | normalizeURL(`${id}?${toURLSearchParams(search).toString()}`),
250 | {
251 | ...init,
252 | method: 'GET',
253 | },
254 | ),
255 | });
256 | }
257 |
258 | let SCOPE: unknown[] | undefined;
259 |
260 | function runWithScope(scope: unknown[], callback: () => T): T {
261 | const parent = SCOPE;
262 | SCOPE = scope;
263 | try {
264 | return callback();
265 | } finally {
266 | SCOPE = parent;
267 | }
268 | }
269 |
270 | async function fnHandler(
271 | id: string,
272 | callback: ThalerFnHandler,
273 | scope: () => unknown[],
274 | value: T,
275 | init: RequestInit = {},
276 | ): Promise {
277 | const instance = patchHeaders('fn', id, init);
278 | const currentScope = scope();
279 | const body = await serializeFunctionBody({
280 | scope: currentScope,
281 | value,
282 | });
283 | return runWithScope(currentScope, async () =>
284 | callback(value, {
285 | request: new Request(normalizeURL(id), {
286 | ...init,
287 | method: 'POST',
288 | body,
289 | }),
290 | response: createResponseInit('fn', id, instance),
291 | }),
292 | );
293 | }
294 |
295 | async function pureHandler(
296 | id: string,
297 | callback: ThalerPureHandler,
298 | value: T,
299 | init: RequestInit = {},
300 | ): Promise {
301 | const instance = patchHeaders('pure', id, init);
302 | return callback(value, {
303 | request: new Request(normalizeURL(id), {
304 | ...init,
305 | method: 'POST',
306 | body: JSON.stringify(await toJSONAsync(value)),
307 | }),
308 | response: createResponseInit('post', id, instance),
309 | });
310 | }
311 |
312 | async function loaderHandler(
313 | id: string,
314 | callback: ThalerLoaderHandler
,
315 | search: P,
316 | init: RequestInit = {},
317 | ): Promise {
318 | const instance = patchHeaders('loader', id, init);
319 | return await callback(search, {
320 | request: new Request(
321 | normalizeURL(`${id}?${toURLSearchParams(search).toString()}`),
322 | {
323 | ...init,
324 | method: 'GET',
325 | },
326 | ),
327 | response: createResponseInit('loader', id, instance),
328 | });
329 | }
330 |
331 | async function actionHandler(
332 | id: string,
333 | callback: ThalerActionHandler
,
334 | formData: P,
335 | init: RequestInit = {},
336 | ): Promise {
337 | const instance = patchHeaders('action', id, init);
338 | return await callback(formData, {
339 | request: new Request(normalizeURL(id), {
340 | ...init,
341 | method: 'POST',
342 | body: toFormData(formData),
343 | }),
344 | response: createResponseInit('action', id, instance),
345 | });
346 | }
347 |
348 | export function $$scope(): unknown[] {
349 | return SCOPE!;
350 | }
351 |
352 | export function $$clone(
353 | [type, id, callback]: HandlerRegistration,
354 | scope: () => unknown[],
355 | ): ThalerFunctions {
356 | switch (type) {
357 | case 'server':
358 | return Object.assign(serverHandler.bind(null, id, callback), {
359 | type,
360 | id,
361 | });
362 | case 'post':
363 | return Object.assign(postHandler.bind(null, id, callback), {
364 | type,
365 | id,
366 | });
367 | case 'get':
368 | return Object.assign(getHandler.bind(null, id, callback), {
369 | type,
370 | id,
371 | });
372 | case 'fn':
373 | return Object.assign(fnHandler.bind(null, id, callback, scope), {
374 | type,
375 | id,
376 | });
377 | case 'pure':
378 | return Object.assign(pureHandler.bind(null, id, callback), {
379 | type,
380 | id,
381 | });
382 | case 'loader':
383 | return Object.assign(loaderHandler.bind(null, id, callback), {
384 | type,
385 | id,
386 | });
387 | case 'action':
388 | return Object.assign(actionHandler.bind(null, id, callback), {
389 | type,
390 | id,
391 | });
392 | default:
393 | throw new Error('unknown registration type');
394 | }
395 | }
396 |
397 | export async function handleRequest(
398 | request: Request,
399 | ): Promise {
400 | const url = new URL(request.url);
401 | const registration = REGISTRATIONS.get(url.pathname);
402 | const instance = request.headers.get(XThalerInstance);
403 | const target = request.headers.get(XThalerID);
404 | if (registration && instance) {
405 | const [type, id, callback] = registration;
406 |
407 | if (target !== id) {
408 | return new Response(
409 | serializeToStream(
410 | instance,
411 | new Error(`Invalid request for ${instance}`),
412 | ),
413 | {
414 | headers: new Headers({
415 | 'Content-Type': 'text/javascript',
416 | [XThalerRequestType]: type,
417 | [XThalerInstance]: instance,
418 | [XThalerID]: id,
419 | }),
420 | status: 500,
421 | },
422 | );
423 | }
424 |
425 | try {
426 | switch (type) {
427 | case 'server':
428 | return await callback(request);
429 | case 'post':
430 | return await callback(fromFormData(await request.formData()), {
431 | request,
432 | });
433 | case 'get':
434 | return await callback(fromURLSearchParams(url.searchParams), {
435 | request,
436 | });
437 | case 'fn': {
438 | const { scope, value } = deserializeData(
439 | await request.json(),
440 | );
441 | const response = createResponseInit('fn', id, instance);
442 | const result = await runWithScope(scope, () =>
443 | callback(value, {
444 | request,
445 | response,
446 | }),
447 | );
448 | const headers = new Headers(response.headers);
449 | return new Response(serializeToStream(instance, result), {
450 | headers,
451 | status: response.status,
452 | statusText: response.statusText,
453 | });
454 | }
455 | case 'pure': {
456 | const value = deserializeData(await request.json());
457 | const response = createResponseInit('pure', id, instance);
458 | const result = await callback(value, { request, response });
459 | const headers = new Headers(response.headers);
460 | return new Response(serializeToStream(instance, result), {
461 | headers,
462 | status: response.status,
463 | statusText: response.statusText,
464 | });
465 | }
466 | case 'loader': {
467 | const value = fromURLSearchParams(url.searchParams);
468 | const response = createResponseInit('loader', id, instance);
469 | const result = await callback(value, { request, response });
470 | const headers = new Headers(response.headers);
471 | return new Response(serializeToStream(instance, result), {
472 | headers,
473 | status: response.status,
474 | statusText: response.statusText,
475 | });
476 | }
477 | case 'action': {
478 | const value = fromFormData(await request.formData());
479 | const response = createResponseInit('action', id, instance);
480 | const result = await callback(value, { request, response });
481 | const headers = new Headers(response.headers);
482 | return new Response(serializeToStream(instance, result), {
483 | headers,
484 | status: response.status,
485 | statusText: response.statusText,
486 | });
487 | }
488 | default:
489 | throw new Error('unexpected type');
490 | }
491 | } catch (error) {
492 | if (import.meta.env.DEV) {
493 | console.error(error);
494 | return new Response(serializeToStream(instance, error), {
495 | headers: new Headers({
496 | 'Content-Type': 'text/javascript',
497 | [XThalerRequestType]: type,
498 | [XThalerInstance]: instance,
499 | [XThalerID]: id,
500 | }),
501 | status: 500,
502 | });
503 | }
504 | return new Response(serializeToStream(instance, new ThalerError(id)), {
505 | headers: new Headers({
506 | 'Content-Type': 'text/javascript',
507 | [XThalerRequestType]: type,
508 | [XThalerInstance]: instance,
509 | [XThalerID]: id,
510 | }),
511 | status: 500,
512 | });
513 | }
514 | }
515 | return undefined;
516 | }
517 |
518 | export function $$ref(id: string, value: T): T {
519 | return createReference(`thaler--${id}`, value);
520 | }
521 |
--------------------------------------------------------------------------------