├── examples ├── astro │ ├── src │ │ ├── env.d.ts │ │ ├── pages │ │ │ ├── [...all].astro │ │ │ └── index.astro │ │ └── components │ │ │ └── Example.tsx │ ├── postcss.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── astro.config.mjs │ └── package.json ├── solidstart │ ├── .gitignore │ ├── src │ │ ├── routes │ │ │ ├── index.tsx │ │ │ └── api │ │ │ │ └── __thaler │ │ │ │ └── [id].ts │ │ ├── entry-client.tsx │ │ ├── app.tsx │ │ ├── entry-server.tsx │ │ └── components │ │ │ └── Example.tsx │ ├── vite.config.ts │ ├── tsconfig.json │ └── package.json └── sveltekit │ ├── src │ ├── routes │ │ ├── +page.svelte │ │ └── styles.css │ ├── app.d.ts │ ├── app.html │ ├── hooks.server.ts │ └── components │ │ └── Example.svelte │ ├── .gitignore │ ├── vite.config.ts │ ├── tsconfig.json │ ├── svelte.config.js │ └── package.json ├── packages ├── vite │ ├── pridepack.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── .gitignore ├── unplugin │ ├── pridepack.json │ ├── tsconfig.json │ ├── README.md │ ├── src │ │ └── index.ts │ ├── package.json │ └── .gitignore └── thaler │ ├── shared │ ├── error.ts │ ├── types.ts │ └── utils.ts │ ├── server │ ├── env.d.ts │ └── index.ts │ ├── pridepack.json │ ├── compiler │ ├── errors.ts │ ├── unwrap-node.ts │ ├── unwrap-path.ts │ ├── checks.ts │ ├── index.ts │ ├── get-foreign-bindings.ts │ ├── imports.ts │ ├── xxhash32.ts │ └── plugin.ts │ ├── tsconfig.json │ ├── LICENSE │ ├── example.js │ ├── src │ └── index.ts │ ├── .gitignore │ ├── package.json │ ├── test │ ├── compiler.test.ts │ └── __snapshots__ │ │ └── compiler.test.ts.snap │ ├── utils │ └── index.ts │ ├── client │ └── index.ts │ └── README.md ├── pnpm-workspace.yaml ├── package.json ├── lerna.json ├── .github └── workflows │ └── main.yml ├── LICENSE ├── .gitignore ├── biome.json └── README.md /examples/astro/src/env.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/solidstart/.gitignore: -------------------------------------------------------------------------------- 1 | .solid -------------------------------------------------------------------------------- /packages/vite/pridepack.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": "es2018" 3 | } 4 | -------------------------------------------------------------------------------- /packages/unplugin/pridepack.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": "es2018" 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**/*' 3 | - 'examples/**/*' -------------------------------------------------------------------------------- /examples/astro/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/sveltekit/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /examples/solidstart/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import Example from '../components/Example'; 2 | 3 | export default function Index() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /examples/solidstart/src/entry-client.tsx: -------------------------------------------------------------------------------- 1 | import { mount, StartClient } from '@solidjs/start/client'; 2 | 3 | mount(() => , document.getElementById('app')); 4 | -------------------------------------------------------------------------------- /packages/thaler/shared/error.ts: -------------------------------------------------------------------------------- 1 | export default class ThalerError extends Error { 2 | constructor(id: string) { 3 | super(`function "${id}" threw an unhandled server-side error.`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/astro/src/pages/[...all].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { handleRequest } from 'thaler/server'; 3 | const result = await handleRequest(Astro.request); 4 | 5 | if (result) { 6 | return result; 7 | } 8 | --- -------------------------------------------------------------------------------- /examples/astro/tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | mode: 'jit', 3 | content: ['./src/**/*.{tsx,astro}'], 4 | darkMode: 'class', // or 'media' or 'class' 5 | variants: {}, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /examples/astro/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Example from "../components/Example"; 3 | 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/sveltekit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | .vercel 10 | .output 11 | vite.config.js.timestamp-* 12 | vite.config.ts.timestamp-* 13 | -------------------------------------------------------------------------------- /packages/thaler/server/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMeta { 2 | readonly env: ImportMetaEnv; 3 | } 4 | 5 | interface ImportMetaEnv { 6 | [key: string]: any; 7 | MODE: string; 8 | DEV: boolean; 9 | PROD: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*", 6 | "examples/*" 7 | ], 8 | "devDependencies": { 9 | "@biomejs/biome": "1.5.2", 10 | "lerna": "^7.4.2", 11 | "typescript": "^5.3.3" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/thaler/pridepack.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": "es2018", 3 | "entrypoints": { 4 | ".": "src/index.ts", 5 | "./compiler": "compiler/index.ts", 6 | "./client": "client/index.ts", 7 | "./server": "server/index.ts", 8 | "./utils": "utils/index.ts" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/sveltekit/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /examples/sveltekit/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %sveltekit.head% 7 | 8 | 9 |
%sveltekit.body%
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/solidstart/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@solidjs/start/config'; 2 | import thalerPlugin from 'unplugin-thaler'; 3 | 4 | console.log(thalerPlugin); 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | thalerPlugin.vite({ 9 | prefix: 'api/__thaler', 10 | mode: 'server', 11 | }), 12 | ], 13 | }); 14 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "pnpm", 3 | "packages": [ 4 | "packages/*", 5 | "examples/*" 6 | ], 7 | "command": { 8 | "version": { 9 | "exact": true 10 | }, 11 | "publish": { 12 | "allowBranch": [ 13 | "main" 14 | ], 15 | "registry": "https://registry.npmjs.org/" 16 | } 17 | }, 18 | "version": "0.9.0" 19 | } 20 | -------------------------------------------------------------------------------- /examples/sveltekit/src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from 'thaler/server'; 2 | 3 | /** @type {import('@sveltejs/kit').Handle} */ 4 | export async function handle({ event, resolve }) { 5 | const thalerResponse = await handleRequest(event.request); 6 | if (thalerResponse) { 7 | return thalerResponse; 8 | } 9 | const response = await resolve(event); 10 | return response; 11 | } 12 | -------------------------------------------------------------------------------- /examples/sveltekit/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | import thalerPlugin from 'unplugin-thaler'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | sveltekit(), 8 | thalerPlugin.vite({ 9 | mode: 'server', 10 | filter: { 11 | include: 'src/**/*.{svelte,ts}', 12 | }, 13 | }), 14 | ], 15 | }); 16 | -------------------------------------------------------------------------------- /packages/vite/src/index.ts: -------------------------------------------------------------------------------- 1 | import thalerUnplugin from 'unplugin-thaler'; 2 | import type { ThalerPluginOptions } from 'unplugin-thaler'; 3 | import type { Plugin } from 'vite'; 4 | 5 | export type { ThalerPluginFilter, ThalerPluginOptions } from 'unplugin-thaler'; 6 | 7 | const thalerPlugin = thalerUnplugin.vite as ( 8 | options: ThalerPluginOptions, 9 | ) => Plugin; 10 | 11 | export default thalerPlugin; 12 | -------------------------------------------------------------------------------- /examples/solidstart/src/app.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { MetaProvider, Title } from '@solidjs/meta'; 3 | import { Router } from '@solidjs/router'; 4 | import { FileRoutes } from '@solidjs/start'; 5 | import { Suspense } from 'solid-js'; 6 | 7 | export default function App() { 8 | return ( 9 | ( 11 | 12 | SolidStart - Basic 13 | {props.children} 14 | 15 | )} 16 | > 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/astro/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "lib": ["ESNext", "DOM"], 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "noEmit": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "types": [ 16 | "astro/client" 17 | ], 18 | "jsx": "preserve", 19 | "jsxImportSource": "solid-js", 20 | }, 21 | "include": ["./src"] 22 | } -------------------------------------------------------------------------------- /examples/astro/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | 3 | import solidJs from '@astrojs/solid-js'; 4 | import tailwind from '@astrojs/tailwind'; 5 | import node from '@astrojs/node'; 6 | import thalerPlugin from 'unplugin-thaler'; 7 | 8 | // https://astro.build/config 9 | export default defineConfig({ 10 | integrations: [solidJs(), tailwind()], 11 | output: 'server', 12 | adapter: node({ 13 | mode: 'standalone', 14 | }), 15 | vite: { 16 | plugins: [ 17 | thalerPlugin.vite({ 18 | mode: 'server', 19 | }), 20 | ], 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/thaler/compiler/errors.ts: -------------------------------------------------------------------------------- 1 | import type * as babel from '@babel/core'; 2 | 3 | export function unexpectedType( 4 | path: babel.NodePath, 5 | received: string, 6 | expected: string, 7 | ): Error { 8 | return path.buildCodeFrameError( 9 | `Unexpected '${received}' (Expected: ${expected})`, 10 | ); 11 | } 12 | 13 | export function unexpectedArgumentLength( 14 | path: babel.NodePath, 15 | received: number, 16 | expected: number, 17 | ): Error { 18 | return path.buildCodeFrameError( 19 | `Unexpected argument length of ${received} (Expected: ${expected})`, 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/thaler/compiler/unwrap-node.ts: -------------------------------------------------------------------------------- 1 | import type * as t from '@babel/types'; 2 | import { isNestedExpression } from './checks'; 3 | 4 | type TypeFilter = (node: t.Node) => node is K; 5 | type TypeCheck = K extends TypeFilter ? U : never; 6 | 7 | export default function unwrapNode boolean>( 8 | node: t.Node, 9 | key: K, 10 | ): TypeCheck | undefined { 11 | if (key(node)) { 12 | return node as TypeCheck; 13 | } 14 | if (isNestedExpression(node)) { 15 | return unwrapNode(node.expression, key); 16 | } 17 | return undefined; 18 | } 19 | -------------------------------------------------------------------------------- /packages/thaler/compiler/unwrap-path.ts: -------------------------------------------------------------------------------- 1 | import type * as babel from '@babel/core'; 2 | import type * as t from '@babel/types'; 3 | import { isNestedExpression, isPathValid } from './checks'; 4 | 5 | type TypeFilter = (node: t.Node) => node is V; 6 | 7 | export default function unwrapPath( 8 | path: unknown, 9 | key: TypeFilter, 10 | ): babel.NodePath | undefined { 11 | if (isPathValid(path, key)) { 12 | return path; 13 | } 14 | if (isPathValid(path, isNestedExpression)) { 15 | return unwrapPath(path.get('expression'), key); 16 | } 17 | return undefined; 18 | } 19 | -------------------------------------------------------------------------------- /examples/solidstart/src/routes/api/__thaler/[id].ts: -------------------------------------------------------------------------------- 1 | import type { APIEvent } from '@solidjs/start/server'; 2 | import { handleRequest } from 'thaler/server'; 3 | 4 | export async function GET({ request }: APIEvent) { 5 | const result = await handleRequest(request); 6 | if (result) { 7 | return result; 8 | } 9 | return new Response(null, { 10 | status: 404, 11 | }); 12 | } 13 | 14 | export async function POST({ request }: APIEvent) { 15 | const result = await handleRequest(request); 16 | if (result) { 17 | return result; 18 | } 19 | return new Response(null, { 20 | status: 404, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /examples/sveltekit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /packages/vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "bundler", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017", 20 | "useDefineForClassFields": false, 21 | "declarationMap": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/solidstart/src/entry-server.tsx: -------------------------------------------------------------------------------- 1 | import { createHandler } from '@solidjs/start/entry'; 2 | import { StartServer } from '@solidjs/start/server'; 3 | 4 | export default createHandler(() => ( 5 | ( 7 | 8 | 9 | 10 | 11 | 12 | {assets} 13 | 14 | 15 |
{children}
16 | {scripts} 17 | 18 | 19 | )} 20 | /> 21 | )); 22 | -------------------------------------------------------------------------------- /packages/unplugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "bundler", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "ES2017", 20 | "useDefineForClassFields": false, 21 | "declarationMap": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/solidstart/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types", "compiler", "client", "server", "shared", "utils"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "jsx": "preserve", 18 | "jsxImportSource": "solid-js", 19 | "esModuleInterop": true, 20 | "target": "es2018" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/astro/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-demo", 3 | "version": "0.9.0", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "build": "astro build", 10 | "preview": "astro preview" 11 | }, 12 | "devDependencies": { 13 | "astro": "^4.1.2", 14 | "autoprefixer": "^10.4.16", 15 | "postcss": "^8.4.33", 16 | "typescript": "^5.3.3", 17 | "unplugin-thaler": "0.9.0" 18 | }, 19 | "dependencies": { 20 | "@astrojs/node": "^7.0.4", 21 | "@astrojs/solid-js": "^4.0.1", 22 | "@astrojs/tailwind": "^5.1.0", 23 | "solid-js": "^1.8.11", 24 | "tailwindcss": "^3.4.1", 25 | "thaler": "0.9.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/thaler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src", "types", "compiler", "client", "server", "shared", "utils"], 4 | "compilerOptions": { 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "bundler", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "target": "es2018", 20 | "useDefineForClassFields": false, 21 | "declarationMap": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/solidstart/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solid-start-demo", 3 | "version": "0.9.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vinxi dev", 7 | "build": "vinxi build", 8 | "start": "vinxi start" 9 | }, 10 | "type": "module", 11 | "devDependencies": { 12 | "@types/node": "^20.11.3", 13 | "postcss": "^8.4.25", 14 | "typescript": "^5.3.3", 15 | "unplugin-thaler": "0.9.0", 16 | "vite": "^4.4.11" 17 | }, 18 | "dependencies": { 19 | "@solidjs/meta": "^0.29.3", 20 | "@solidjs/router": "^0.10.9", 21 | "@solidjs/start": "^0.4.9", 22 | "solid-js": "^1.8.11", 23 | "thaler": "0.9.0", 24 | "vinxi": "^0.1.4" 25 | }, 26 | "engines": { 27 | "node": ">=16.8" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/sveltekit/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: [vitePreprocess()], 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | }, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: pnpm/action-setup@v2.2.2 11 | with: 12 | version: 8 13 | run_install: | 14 | - recursive: true 15 | args: [--frozen-lockfile] 16 | 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | cache: 'pnpm' 21 | 22 | - name: Clean 23 | run: pnpm recursive run clean 24 | env: 25 | CI: true 26 | 27 | - name: Build 28 | run: pnpm recursive run build 29 | env: 30 | CI: true 31 | 32 | - name: Begin Tests 33 | run: pnpm recursive run test 34 | env: 35 | CI: true 36 | -------------------------------------------------------------------------------- /examples/sveltekit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-demo", 3 | "version": "0.9.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 11 | }, 12 | "dependencies": { 13 | "@sveltejs/vite-plugin-svelte": "^3.0.1", 14 | "thaler": "0.9.0" 15 | }, 16 | "devDependencies": { 17 | "@sveltejs/adapter-auto": "^3.1.0", 18 | "@sveltejs/kit": "^2.3.2", 19 | "@types/cookie": "^0.5.1", 20 | "svelte": "^4.2.8", 21 | "svelte-check": "^3.6.3", 22 | "tslib": "^2.6.2", 23 | "typescript": "^5.3.3", 24 | "unplugin-thaler": "0.9.0", 25 | "vite": "^5.0.11" 26 | }, 27 | "type": "module" 28 | } 29 | -------------------------------------------------------------------------------- /examples/sveltekit/src/components/Example.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | 34 |
35 | {#await data} 36 |

Loading

37 | {:then value} 38 |

{value.data}

39 | {#await value.delayed} 40 |

Loading

41 | {:then delayed} 42 |

Delayed: {delayed}

43 | {/await} 44 | {/await} 45 |
46 | -------------------------------------------------------------------------------- /packages/vite/README.md: -------------------------------------------------------------------------------- 1 | # vite-plugin-thaler 2 | 3 | > Vite plugin for [`thaler`](https://github.com/lxsmnsyc/thaler) 4 | 5 | [![NPM](https://img.shields.io/npm/v/vite-plugin-thaler.svg)](https://www.npmjs.com/package/vite-plugin-thaler) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install thaler 11 | npm install --D vite-plugin-thaler 12 | ``` 13 | 14 | ```bash 15 | yarn add thaler 16 | yarn add -D vite-plugin-thaler 17 | ``` 18 | 19 | ```bash 20 | pnpm add thaler 21 | pnpm add -D vite-plugin-thaler 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```js 27 | import thaler from 'vite-plugin-thaler'; 28 | 29 | ///... 30 | thaler({ 31 | filter: { 32 | include: 'src/**/*.{ts,js,tsx,jsx}', 33 | exclude: 'node_modules/**/*.{ts,js,tsx,jsx}', 34 | }, 35 | }) 36 | ``` 37 | 38 | ## Sponsors 39 | 40 | ![Sponsors](https://github.com/lxsmnsyc/sponsors/blob/main/sponsors.svg?raw=true) 41 | 42 | ## License 43 | 44 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc) 45 | -------------------------------------------------------------------------------- /packages/thaler/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2023 Alexis Munsayac 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexis Munsayac 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/solidstart/src/components/Example.tsx: -------------------------------------------------------------------------------- 1 | import { createResource, createSignal, Suspense } from 'solid-js'; 2 | import { fn$ } from 'thaler'; 3 | import { debounce } from 'thaler/utils'; 4 | 5 | async function sleep(value: T, ms: number): Promise { 6 | return new Promise(res => { 7 | setTimeout(res, ms, value); 8 | }); 9 | } 10 | 11 | export default function Example() { 12 | const [state, setState] = createSignal(0); 13 | 14 | const prefix = 'Server Count'; 15 | 16 | const serverCount = debounce( 17 | fn$((value: number) => { 18 | console.log('Received', value); 19 | return { 20 | data: `${prefix}: ${value}`, 21 | delayed: sleep(`Delayed ${prefix}: ${value}`, 1000), 22 | }; 23 | }), 24 | { 25 | key: () => 'sleep', 26 | }, 27 | ); 28 | 29 | const [data] = createResource(state, value => serverCount(value)); 30 | 31 | function increment() { 32 | setState(c => c + 1); 33 | } 34 | 35 | return ( 36 | <> 37 | 38 |
39 | Loading}> 40 |

{data()?.data}

41 |
42 |
43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /packages/thaler/compiler/checks.ts: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import type * as babel from '@babel/core'; 3 | 4 | export function getImportSpecifierName(specifier: t.ImportSpecifier): string { 5 | if (t.isIdentifier(specifier.imported)) { 6 | return specifier.imported.name; 7 | } 8 | return specifier.imported.value; 9 | } 10 | 11 | type TypeFilter = (node: t.Node) => node is V; 12 | 13 | export function isPathValid( 14 | path: unknown, 15 | key: TypeFilter, 16 | ): path is babel.NodePath { 17 | return key((path as babel.NodePath).node); 18 | } 19 | 20 | export type NestedExpression = 21 | | t.ParenthesizedExpression 22 | | t.TypeCastExpression 23 | | t.TSAsExpression 24 | | t.TSSatisfiesExpression 25 | | t.TSNonNullExpression 26 | | t.TSInstantiationExpression 27 | | t.TSTypeAssertion; 28 | 29 | export function isNestedExpression(node: t.Node): node is NestedExpression { 30 | switch (node.type) { 31 | case 'ParenthesizedExpression': 32 | case 'TypeCastExpression': 33 | case 'TSAsExpression': 34 | case 'TSSatisfiesExpression': 35 | case 'TSNonNullExpression': 36 | case 'TSTypeAssertion': 37 | case 'TSInstantiationExpression': 38 | return true; 39 | default: 40 | return false; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/unplugin/README.md: -------------------------------------------------------------------------------- 1 | # unplugin-thaler 2 | 3 | > [Unplugin](https://github.com/unjs/unplugin) for [`thaler`](https://github.com/lxsmnsyc/thaler) 4 | 5 | [![NPM](https://img.shields.io/npm/v/unplugin-thaler.svg)](https://www.npmjs.com/package/unplugin-thaler) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install thaler 11 | npm install --D unplugin-thaler 12 | ``` 13 | 14 | ```bash 15 | yarn add thaler 16 | yarn add -D unplugin-thaler 17 | ``` 18 | 19 | ```bash 20 | pnpm add thaler 21 | pnpm add -D unplugin-thaler 22 | ``` 23 | 24 | ## Usage 25 | 26 | Please check out [`unplugin`](https://github.com/unjs/unplugin) to know more about how to use the plugins with `unplugin-thaler` in your target bundler. 27 | 28 | ```js 29 | import thaler from 'unplugin-thaler'; 30 | 31 | // Example: Rollup 32 | thaler.rollup({ 33 | mode: 'server', // or 'client' 34 | filter: { 35 | include: 'src/**/*.{ts,js,tsx,jsx}', 36 | exclude: 'node_modules/**/*.{ts,js,tsx,jsx}', 37 | }, 38 | }) 39 | ``` 40 | 41 | ## Sponsors 42 | 43 | ![Sponsors](https://github.com/lxsmnsyc/sponsors/blob/main/sponsors.svg?raw=true) 44 | 45 | ## License 46 | 47 | MIT © [lxsmnsyc](https://github.com/lxsmnsyc) 48 | -------------------------------------------------------------------------------- /packages/thaler/compiler/index.ts: -------------------------------------------------------------------------------- 1 | import * as babel from '@babel/core'; 2 | import path from 'path'; 3 | import type { PluginOptions } from './plugin'; 4 | import thalerBabel from './plugin'; 5 | 6 | export type Options = Omit; 7 | 8 | export interface CompileResult { 9 | code: string; 10 | map: babel.BabelFileResult['map']; 11 | } 12 | 13 | export async function compile( 14 | id: string, 15 | code: string, 16 | options: Options, 17 | ): Promise { 18 | const pluginOption = [ 19 | thalerBabel, 20 | { 21 | source: id, 22 | prefix: options.prefix, 23 | mode: options.mode, 24 | functions: options.functions, 25 | }, 26 | ]; 27 | const plugins: NonNullable< 28 | NonNullable['plugins'] 29 | > = ['jsx']; 30 | if (/\.[mc]?tsx?$/i.test(id)) { 31 | plugins.push('typescript'); 32 | } 33 | const result = await babel.transformAsync(code, { 34 | plugins: [pluginOption], 35 | parserOpts: { 36 | plugins, 37 | }, 38 | filename: path.basename(id), 39 | ast: false, 40 | sourceMaps: true, 41 | configFile: false, 42 | babelrc: false, 43 | sourceFileName: id, 44 | }); 45 | 46 | if (result) { 47 | return { 48 | code: result.code || '', 49 | map: result.map, 50 | }; 51 | } 52 | throw new Error('invariant'); 53 | } 54 | -------------------------------------------------------------------------------- /packages/thaler/example.js: -------------------------------------------------------------------------------- 1 | import compile from 'thaler/compiler'; 2 | 3 | const serverOptions = { 4 | prefix: 'example', 5 | mode: 'server', 6 | }; 7 | 8 | const FILE = 'src/index.ts'; 9 | 10 | const code = ` 11 | import { createResource, createSignal, Suspense } from 'solid-js'; 12 | import { fn$, ref$ } from 'thaler'; 13 | 14 | const sleep = (ms: number) => new Promise((res) => { 15 | setTimeout(res, ms, true); 16 | }); 17 | 18 | const sleepingValue = ref$(async (value: number) => { 19 | await sleep(1000); 20 | console.log('Received', value); 21 | return value; 22 | }); 23 | 24 | export default function Example() { 25 | const [state, setState] = createSignal(0); 26 | 27 | const prefix = 'Server Count'; 28 | 29 | const serverCount = fn$(async ([cb, value]: [typeof sleepingValue, number]) => ( 30 | \`\${prefix}: \${await cb(value)}\` 31 | )); 32 | 33 | const [data] = createResource(state, (value) => serverCount([sleepingValue, value])); 34 | 35 | function increment() { 36 | setState((c) => c + 1); 37 | } 38 | 39 | return ( 40 | <> 41 | 44 |
45 | Loading}> 46 |

{data()}

47 |
48 |
49 | 50 | ); 51 | } 52 | 53 | `; 54 | 55 | console.log((await compile(FILE, code, serverOptions)).code); 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | dist -------------------------------------------------------------------------------- /packages/unplugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import { compile } from 'thaler/compiler'; 2 | import type { Options } from 'thaler/compiler'; 3 | import { createUnplugin } from 'unplugin'; 4 | import { createFilter } from '@rollup/pluginutils'; 5 | import type { FilterPattern } from '@rollup/pluginutils'; 6 | 7 | export interface ThalerPluginFilter { 8 | include?: FilterPattern; 9 | exclude?: FilterPattern; 10 | } 11 | 12 | export interface ThalerPluginOptions extends Options { 13 | filter?: ThalerPluginFilter; 14 | } 15 | 16 | const DEFAULT_INCLUDE = 'src/**/*.{jsx,tsx,ts,js,mjs,cjs}'; 17 | const DEFAULT_EXCLUDE = 'node_modules/**/*.{jsx,tsx,ts,js,mjs,cjs}'; 18 | 19 | const thalerPlugin = createUnplugin((options: ThalerPluginOptions) => { 20 | const filter = createFilter( 21 | options.filter?.include || DEFAULT_INCLUDE, 22 | options.filter?.exclude || DEFAULT_EXCLUDE, 23 | ); 24 | 25 | let env: ThalerPluginOptions['env']; 26 | 27 | return { 28 | name: 'thaler', 29 | vite: { 30 | enforce: 'pre', 31 | configResolved(config) { 32 | env = config.mode !== 'production' ? 'development' : 'production'; 33 | }, 34 | transform(code, id, opts) { 35 | if (filter(id)) { 36 | return compile(id, code, { 37 | ...options, 38 | mode: opts?.ssr ? 'server' : 'client', 39 | env, 40 | }); 41 | } 42 | return undefined; 43 | }, 44 | }, 45 | transformInclude(id) { 46 | return filter(id); 47 | }, 48 | transform(code, id) { 49 | return compile(id, code, options); 50 | }, 51 | }; 52 | }); 53 | 54 | export default thalerPlugin; 55 | -------------------------------------------------------------------------------- /examples/astro/src/components/Example.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'solid-js'; 2 | import { createResource, createSignal, onMount, Suspense } from 'solid-js'; 3 | import { fn$ } from 'thaler'; 4 | import { debounce } from 'thaler/utils'; 5 | 6 | async function sleep(value: T, ms: number): Promise { 7 | return new Promise(res => { 8 | setTimeout(res, ms, value); 9 | }); 10 | } 11 | 12 | export default function Example(): JSX.Element { 13 | const [state, setState] = createSignal(0); 14 | 15 | const prefix = 'Server Count'; 16 | 17 | const serverCount = debounce( 18 | fn$((value: number) => { 19 | console.log('Received', value); 20 | return { 21 | data: `${prefix}: ${value}`, 22 | delayed: sleep(`Delayed ${prefix}: ${value}`, 1000), 23 | }; 24 | }), 25 | { 26 | key: () => 'example', 27 | }, 28 | ); 29 | 30 | const [data] = createResource(state, async value => serverCount(value)); 31 | 32 | function increment(): void { 33 | setState(c => c + 1); 34 | } 35 | 36 | const example = fn$(async function* foo(items: string[]) { 37 | for (const item of items) { 38 | yield sleep(item, 1000); 39 | } 40 | }); 41 | 42 | onMount(() => { 43 | (async (): Promise => { 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 | [![NPM](https://img.shields.io/npm/v/thaler.svg)](https://www.npmjs.com/package/thaler) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](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 | ![Sponsors](https://github.com/lxsmnsyc/sponsors/blob/main/sponsors.svg?raw=true) 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 | [![NPM](https://img.shields.io/npm/v/thaler.svg)](https://www.npmjs.com/package/thaler) [![JavaScript Style Guide](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](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 | ![Sponsors](https://github.com/lxsmnsyc/sponsors/blob/main/sponsors.svg?raw=true) 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 | --------------------------------------------------------------------------------