@tourik/yeet 0.0.0-stage → 0.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -2
- package/bin/yeet.js +33 -0
- package/client.ts +272 -0
- package/package.json +26 -4
- package/react.ts +107 -0
- package/server.d.ts +244 -0
- package/sign-in.ts +103 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thanos Tourikas
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,144 @@
|
|
|
1
|
-
#
|
|
1
|
+
# yeet
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
yeet hosts small apps that AI agents build, deploy and check on their own. An app is one folder
|
|
4
|
+
of TypeScript: a server with a database, sign-in and live queries, and a React page. This package
|
|
5
|
+
holds the `yeet` command and the SDK that apps import.
|
|
6
|
+
|
|
7
|
+
## Start an app
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @tourik/yeet init notes
|
|
11
|
+
cd notes
|
|
12
|
+
npx yeet dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- `yeet init notes` creates the folder `notes` with a small todo app in it: a table, a search, a
|
|
16
|
+
query, two mutations and a React page. It installs the app's npm packages and starts a git
|
|
17
|
+
repository. `notes` is also the app's id, which names it on the server.
|
|
18
|
+
- The app's `package.json` holds this package under the name `yeet`. So the app imports
|
|
19
|
+
`yeet/server` and `yeet/react`, and `npx yeet` runs the command.
|
|
20
|
+
- `yeet dev` serves the app at the address it prints, and reloads on every save. It starts on an
|
|
21
|
+
empty database and deletes it when it stops.
|
|
22
|
+
|
|
23
|
+
Then open your coding agent in the folder and describe the app you want. `yeet init` wrote
|
|
24
|
+
`AGENTS.md`, which tells the agent how to write, run, check and deploy a yeet app, and a
|
|
25
|
+
`CLAUDE.md` that points Claude Code at it. `npx yeet agents-md` rewrites yeet's part of
|
|
26
|
+
`AGENTS.md` after an update.
|
|
27
|
+
|
|
28
|
+
Without a name, `yeet init` starts the app in the current folder, under the folder's name. It
|
|
29
|
+
keeps every file that is already there. If it kept a `package.json`, it prints the `npm install`
|
|
30
|
+
command for the packages that file lacks.
|
|
31
|
+
|
|
32
|
+
## What an app looks like
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
server/index.ts the tables, and the functions that clients call
|
|
36
|
+
client/ the web page: index.html, index.tsx, index.css, public/
|
|
37
|
+
shared/ TypeScript that both import
|
|
38
|
+
yeet.json the app's id
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The server, in `server/index.ts`:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { defineSchema, defineTable, mutation, query, v } from "yeet/server";
|
|
45
|
+
|
|
46
|
+
export default defineSchema({
|
|
47
|
+
todos: defineTable({ owner: v.union(v.string(), v.null()), text: v.string() })
|
|
48
|
+
.index("by_owner", ["owner"]),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const todos = query({
|
|
52
|
+
handler: (ctx) =>
|
|
53
|
+
ctx.db.query("todos").withIndex("by_owner", (q) => q.eq("owner", ctx.auth.userId)).collect(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const addTodo = mutation({
|
|
57
|
+
args: { text: v.string() },
|
|
58
|
+
handler: (ctx, { text }) => ctx.db.insert("todos", { owner: ctx.auth.userId, text }),
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The page, in `client/index.tsx`:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { createRoot } from "react-dom/client";
|
|
66
|
+
import { useMutation, useQuery, YeetProvider } from "yeet/react";
|
|
67
|
+
|
|
68
|
+
declare module "yeet/react" {
|
|
69
|
+
interface Register {
|
|
70
|
+
server: typeof import("../server/index");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function Todos() {
|
|
75
|
+
const todos = useQuery("todos");
|
|
76
|
+
const addTodo = useMutation("addTodo");
|
|
77
|
+
if (todos === undefined) return <p>Loading…</p>;
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
<button onClick={() => addTodo({ text: "Buy milk" })}>Add</button>
|
|
81
|
+
<ul>
|
|
82
|
+
{todos.map((todo) => (
|
|
83
|
+
<li key={todo._id}>{String(todo.text)}</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
createRoot(document.getElementById("root")!).render(
|
|
91
|
+
<YeetProvider>
|
|
92
|
+
<Todos />
|
|
93
|
+
</YeetProvider>,
|
|
94
|
+
);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`useQuery` keeps its result up to date: when a mutation changes what a query read, every open
|
|
98
|
+
page gets the new result.
|
|
99
|
+
|
|
100
|
+
## The server API
|
|
101
|
+
|
|
102
|
+
The API resembles Convex's, but it is its own. `server.d.ts` in this package declares all of it.
|
|
103
|
+
|
|
104
|
+
- A `query` only reads. A `mutation` reads and writes in one transaction. An `action` can `fetch`
|
|
105
|
+
and wait, and reaches the data through `ctx.runQuery` and `ctx.runMutation`.
|
|
106
|
+
- `ctx.auth.userId` is the signed-in user's id, or `null`.
|
|
107
|
+
- Tables can have indexes and text search. Functions can run later (`ctx.scheduler`) or on a
|
|
108
|
+
timetable (`cronJobs()`). An app can answer plain HTTP under `/api/` (`httpRouter()`).
|
|
109
|
+
- Server code imports only `yeet/server` and its own files: no npm package. It runs in neither
|
|
110
|
+
Node nor a browser, so `crypto`, `TextEncoder`, `btoa` and `Buffer` do not exist. Client code
|
|
111
|
+
can import npm packages.
|
|
112
|
+
- There is no `v.any()`, `.filter()`, `.paginate()` or `ctx.auth.getUserIdentity()`: `yeet check`
|
|
113
|
+
reports each use with its line.
|
|
114
|
+
|
|
115
|
+
## Commands
|
|
116
|
+
|
|
117
|
+
Every command takes `--json`, and `npx yeet --help` lists them all.
|
|
118
|
+
|
|
119
|
+
| Command | What it does |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `yeet init [name]` | Starts a new app, in the new folder `<name>` or in the current one |
|
|
122
|
+
| `yeet dev` | Serves the app locally, on an empty database |
|
|
123
|
+
| `yeet run <name> '<json args>' --port <port>` | Calls a function on a running `yeet dev` |
|
|
124
|
+
| `yeet logs --port <port> --errors` | Shows the calls that failed, with their arguments |
|
|
125
|
+
| `yeet check` | Checks the types, the build, and the new code on a copy of the live data |
|
|
126
|
+
| `yeet deploy` | Builds the app and makes it live |
|
|
127
|
+
| `yeet deploy --preview <name>` | Serves the code at its own address, on a copy of the live data |
|
|
128
|
+
| `yeet status`, `yeet logs` | Show the deployed app |
|
|
129
|
+
| `yeet rollback` | Brings back the previous deploy |
|
|
130
|
+
| `yeet login` | Logs in through the browser |
|
|
131
|
+
| `yeet mcp` | Serves the main commands to an agent as MCP tools |
|
|
132
|
+
|
|
133
|
+
## Deploying
|
|
134
|
+
|
|
135
|
+
- Without a login, `npx yeet deploy` creates an unclaimed app at a random address under
|
|
136
|
+
`yeet.tourik.dev`. It expires 7 days after its last deploy, and it cannot `fetch` or keep
|
|
137
|
+
secrets.
|
|
138
|
+
- With an account, `npx yeet login` and then `npx yeet deploy` serve the app at
|
|
139
|
+
`https://<id>.yeet.tourik.dev`. `npx yeet claim` gives an unclaimed app to your account.
|
|
140
|
+
- Each deployed app has its own database. `npx yeet access` shows and sets who may sign in to it.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/bin/yeet.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs the yeet binary of the package for this computer, which npm installed as an optional
|
|
3
|
+
// dependency of @tourik/yeet.
|
|
4
|
+
const { spawn } = require("node:child_process");
|
|
5
|
+
|
|
6
|
+
const targets = Object.keys(require("../package.json").optionalDependencies).map((name) =>
|
|
7
|
+
name.slice("@tourik/yeet-".length),
|
|
8
|
+
);
|
|
9
|
+
const target = `${process.platform}-${process.arch}`;
|
|
10
|
+
let binary;
|
|
11
|
+
try {
|
|
12
|
+
binary = require.resolve(`@tourik/yeet-${target}/bin/yeet`);
|
|
13
|
+
} catch {
|
|
14
|
+
console.error(
|
|
15
|
+
targets.includes(target)
|
|
16
|
+
? `the package @tourik/yeet-${target} is not installed; install again without --omit=optional, or without a package-lock.json made on another computer`
|
|
17
|
+
: `yeet has no build for ${target}; it runs on ${targets.join(", ")}`,
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
22
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
23
|
+
process.on(signal, () => child.kill(signal));
|
|
24
|
+
}
|
|
25
|
+
child.on("error", (error) => {
|
|
26
|
+
console.error(`cannot run ${binary}: ${error.message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
29
|
+
child.on("exit", (code, signal) => {
|
|
30
|
+
if (code !== null) process.exit(code);
|
|
31
|
+
process.removeAllListeners(signal);
|
|
32
|
+
process.kill(process.pid, signal);
|
|
33
|
+
});
|
package/client.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { Value } from "./server";
|
|
2
|
+
|
|
3
|
+
export type Args = Record<string, Value>;
|
|
4
|
+
|
|
5
|
+
export type QueryState = { value: unknown } | { error: Error };
|
|
6
|
+
|
|
7
|
+
export interface AuthState {
|
|
8
|
+
readonly isLoading: boolean;
|
|
9
|
+
readonly userId: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Subscription {
|
|
13
|
+
id: number;
|
|
14
|
+
name: string;
|
|
15
|
+
args: Args;
|
|
16
|
+
state: QueryState | undefined;
|
|
17
|
+
listeners: Set<() => void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PendingMutation {
|
|
21
|
+
resolve(value: unknown): void;
|
|
22
|
+
reject(error: Error): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ServerMessage =
|
|
26
|
+
| { type: "result"; id: number; value: unknown; version: number }
|
|
27
|
+
| { type: "mutated"; id: number; value: unknown; version: number }
|
|
28
|
+
| { type: "error"; id: number | null; message: string }
|
|
29
|
+
| { type: "auth"; userId: string | null }
|
|
30
|
+
| { type: "client"; build: string }
|
|
31
|
+
| { type: "reload" };
|
|
32
|
+
|
|
33
|
+
const MAX_RECONNECT_DELAY_MS = 5000;
|
|
34
|
+
|
|
35
|
+
function sortedKeys(value: Value | undefined): Value | undefined {
|
|
36
|
+
if (Array.isArray(value)) return value.map(sortedKeys) as Value[];
|
|
37
|
+
if (value === null || typeof value !== "object") return value;
|
|
38
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortedKeys(value[key])]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** React Native's WebSocket, which takes headers as a third argument; browsers ignore it. */
|
|
42
|
+
type NativeWebSocket = new (
|
|
43
|
+
url: string,
|
|
44
|
+
protocols: string | string[] | undefined,
|
|
45
|
+
options: { headers: Record<string, string> },
|
|
46
|
+
) => WebSocket;
|
|
47
|
+
|
|
48
|
+
interface Listening {
|
|
49
|
+
addEventListener(type: string, listener: () => void, options: { once: boolean }): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The browser globals the client uses, typed here because React Native's TypeScript setup has no
|
|
54
|
+
* browser types.
|
|
55
|
+
*/
|
|
56
|
+
interface Page extends Listening {
|
|
57
|
+
readonly document: Listening & { readonly hidden: boolean; hasFocus(): boolean };
|
|
58
|
+
readonly location: { readonly protocol: string; readonly host: string; reload(): void };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The web page the client runs on, which reloads after a deploy, or undefined in a phone app. */
|
|
62
|
+
export function page() {
|
|
63
|
+
return "document" in globalThis ? (globalThis as unknown as Page) : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reloads the page at once when the user is not on it, or else as soon as they leave it. */
|
|
67
|
+
function reloadWhenLeft(web: Page) {
|
|
68
|
+
if (web.document.hidden || !web.document.hasFocus()) return web.location.reload();
|
|
69
|
+
const reload = () => web.location.reload();
|
|
70
|
+
web.addEventListener("blur", reload, { once: true });
|
|
71
|
+
web.document.addEventListener("visibilitychange", reload, { once: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function queryKey(name: string, args: Args) {
|
|
75
|
+
return `${name}:${JSON.stringify(sortedKeys(args))}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One WebSocket to a yeet app, shared by every query and mutation of a page. It is open while
|
|
80
|
+
* anything listens to a query or to the sign-in state.
|
|
81
|
+
*/
|
|
82
|
+
export class YeetClient {
|
|
83
|
+
private socket: WebSocket | null = null;
|
|
84
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
85
|
+
private listeners = 0;
|
|
86
|
+
private nextId = 1;
|
|
87
|
+
private reconnectAttempts = 0;
|
|
88
|
+
private readonly subscriptions = new Map<string, Subscription>();
|
|
89
|
+
private readonly pending = new Map<number, PendingMutation>();
|
|
90
|
+
private auth: AuthState = { isLoading: true, userId: null };
|
|
91
|
+
private build: string | undefined;
|
|
92
|
+
private readonly authListeners = new Set<() => void>();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `url` is the app's WebSocket, such as `wss://wovies.yeet.tourik.dev/sync`. A phone app passes
|
|
96
|
+
* the `token` from `yeet/sign-in`; a web page signs in with its cookie instead.
|
|
97
|
+
*/
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly url: string,
|
|
100
|
+
private readonly token?: string,
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
subscribe(name: string, args: Args, listener: () => void): () => void {
|
|
104
|
+
const key = queryKey(name, args);
|
|
105
|
+
let subscription = this.subscriptions.get(key);
|
|
106
|
+
if (subscription === undefined) {
|
|
107
|
+
subscription = { id: this.nextId++, name, args, state: undefined, listeners: new Set() };
|
|
108
|
+
this.subscriptions.set(key, subscription);
|
|
109
|
+
this.sendSubscribe(subscription);
|
|
110
|
+
}
|
|
111
|
+
subscription.listeners.add(listener);
|
|
112
|
+
const subscribed = subscription;
|
|
113
|
+
const release = this.hold();
|
|
114
|
+
return () => {
|
|
115
|
+
subscribed.listeners.delete(listener);
|
|
116
|
+
if (subscribed.listeners.size === 0) {
|
|
117
|
+
this.subscriptions.delete(key);
|
|
118
|
+
this.send({ type: "unsubscribe", id: subscribed.id });
|
|
119
|
+
}
|
|
120
|
+
release();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
queryState(name: string, args: Args): QueryState | undefined {
|
|
125
|
+
return this.subscriptions.get(queryKey(name, args))?.state;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolves once the mutation committed, after every subscribed query shows its writes. */
|
|
129
|
+
mutation(name: string, args: Args): Promise<unknown> {
|
|
130
|
+
return this.request("mutate", name, args);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Resolves with the action's value once it finished. */
|
|
134
|
+
action(name: string, args: Args): Promise<unknown> {
|
|
135
|
+
return this.request("call", name, args);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
authState(): AuthState {
|
|
139
|
+
return this.auth;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
onAuthChange(listener: () => void): () => void {
|
|
143
|
+
this.authListeners.add(listener);
|
|
144
|
+
const release = this.hold();
|
|
145
|
+
return () => {
|
|
146
|
+
this.authListeners.delete(listener);
|
|
147
|
+
release();
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Closes the WebSocket and opens a new one at once, even when the old one still looks open,
|
|
153
|
+
* as it may after the device slept. Queries subscribe again; a mutation in flight fails.
|
|
154
|
+
*/
|
|
155
|
+
reconnect() {
|
|
156
|
+
if (this.listeners === 0) return;
|
|
157
|
+
this.disconnect();
|
|
158
|
+
this.connect();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private hold() {
|
|
162
|
+
if (this.listeners++ === 0 && this.socket === null) this.connect();
|
|
163
|
+
let released = false;
|
|
164
|
+
return () => {
|
|
165
|
+
if (released) return;
|
|
166
|
+
released = true;
|
|
167
|
+
this.listeners--;
|
|
168
|
+
// React StrictMode unsubscribes and subscribes again right away; closing in between would
|
|
169
|
+
// open a second socket for nothing.
|
|
170
|
+
void Promise.resolve().then(() => {
|
|
171
|
+
if (this.listeners === 0) this.disconnect();
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private connect() {
|
|
177
|
+
clearTimeout(this.reconnectTimer);
|
|
178
|
+
const socket =
|
|
179
|
+
this.token === undefined
|
|
180
|
+
? new WebSocket(this.url)
|
|
181
|
+
: new (WebSocket as unknown as NativeWebSocket)(this.url, undefined, {
|
|
182
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
183
|
+
});
|
|
184
|
+
this.socket = socket;
|
|
185
|
+
socket.onopen = () => {
|
|
186
|
+
this.reconnectAttempts = 0;
|
|
187
|
+
for (const subscription of this.subscriptions.values()) this.sendSubscribe(subscription);
|
|
188
|
+
};
|
|
189
|
+
socket.onmessage = (event) => this.receive(JSON.parse(event.data as string) as ServerMessage);
|
|
190
|
+
socket.onclose = () => {
|
|
191
|
+
this.dropConnection();
|
|
192
|
+
const delay = Math.min(250 * 2 ** this.reconnectAttempts++, MAX_RECONNECT_DELAY_MS);
|
|
193
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private disconnect() {
|
|
198
|
+
clearTimeout(this.reconnectTimer);
|
|
199
|
+
this.reconnectAttempts = 0;
|
|
200
|
+
if (this.socket === null) return;
|
|
201
|
+
this.socket.onclose = null;
|
|
202
|
+
this.socket.close();
|
|
203
|
+
this.dropConnection();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private dropConnection() {
|
|
207
|
+
this.socket = null;
|
|
208
|
+
for (const mutation of this.pending.values()) {
|
|
209
|
+
mutation.reject(new Error("connection lost before the server replied; the call may have run"));
|
|
210
|
+
}
|
|
211
|
+
this.pending.clear();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private receive(message: ServerMessage) {
|
|
215
|
+
switch (message.type) {
|
|
216
|
+
case "result":
|
|
217
|
+
if (this.pending.has(message.id)) return this.settle(message.id)?.resolve(message.value);
|
|
218
|
+
return this.setQueryState(message.id, { value: message.value });
|
|
219
|
+
case "mutated":
|
|
220
|
+
return this.settle(message.id)?.resolve(message.value);
|
|
221
|
+
case "error":
|
|
222
|
+
if (message.id === null) return console.error(`yeet: ${message.message}`);
|
|
223
|
+
if (this.pending.has(message.id)) return this.settle(message.id)?.reject(new Error(message.message));
|
|
224
|
+
return this.setQueryState(message.id, { error: new Error(message.message) });
|
|
225
|
+
case "auth":
|
|
226
|
+
this.auth = { isLoading: false, userId: message.userId };
|
|
227
|
+
for (const listener of this.authListeners) listener();
|
|
228
|
+
return;
|
|
229
|
+
case "client":
|
|
230
|
+
if (this.build === undefined) this.build = message.build;
|
|
231
|
+
else if (message.build !== this.build) {
|
|
232
|
+
const web = page();
|
|
233
|
+
if (web) reloadWhenLeft(web);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
case "reload":
|
|
237
|
+
page()?.location.reload();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private request(type: "mutate" | "call", name: string, args: Args): Promise<unknown> {
|
|
243
|
+
if (this.socket?.readyState !== WebSocket.OPEN) {
|
|
244
|
+
return Promise.reject(new Error(`cannot run ${name}: not connected to the server`));
|
|
245
|
+
}
|
|
246
|
+
const id = this.nextId++;
|
|
247
|
+
this.send({ type, id, name, args });
|
|
248
|
+
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private settle(id: number) {
|
|
252
|
+
const mutation = this.pending.get(id);
|
|
253
|
+
this.pending.delete(id);
|
|
254
|
+
return mutation;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private setQueryState(id: number, state: QueryState) {
|
|
258
|
+
for (const subscription of this.subscriptions.values()) {
|
|
259
|
+
if (subscription.id !== id) continue;
|
|
260
|
+
subscription.state = state;
|
|
261
|
+
for (const listener of subscription.listeners) listener();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private sendSubscribe({ id, name, args }: Subscription) {
|
|
266
|
+
this.send({ type: "subscribe", id, name, args });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private send(message: object) {
|
|
270
|
+
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
|
|
271
|
+
}
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tourik/yeet",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.2",
|
|
4
|
+
"description": "Small apps with a database, sign-in and live updates: the yeet command and its SDK",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./server": {
|
|
8
|
+
"types": "./server.d.ts"
|
|
9
|
+
},
|
|
10
|
+
"./react": "./react.ts",
|
|
11
|
+
"./sign-in": "./sign-in.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"esbuild": "^0.28.2"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"react": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"yeet": "bin/yeet.js"
|
|
21
|
+
},
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"@tourik/yeet-linux-x64": "0.1.0-rc.2",
|
|
24
|
+
"@tourik/yeet-linux-arm64": "0.1.0-rc.2",
|
|
25
|
+
"@tourik/yeet-darwin-arm64": "0.1.0-rc.2",
|
|
26
|
+
"@tourik/yeet-darwin-x64": "0.1.0-rc.2"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/react.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createContext, createElement, useCallback, useContext, useMemo, useSyncExternalStore, type ReactNode } from "react";
|
|
2
|
+
import { page, queryKey, YeetClient, type Args } from "./client";
|
|
3
|
+
import type { RegisteredFunction } from "./server";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Declare your server module here to type every query and mutation by name:
|
|
7
|
+
* `declare module "yeet/react" { interface Register { server: typeof import("../server") } }`
|
|
8
|
+
*/
|
|
9
|
+
export interface Register {}
|
|
10
|
+
|
|
11
|
+
type Unregistered = Record<string, RegisteredFunction<never, Args, unknown, false>>;
|
|
12
|
+
|
|
13
|
+
type Server = Register extends { server: infer S } ? S : Unregistered;
|
|
14
|
+
|
|
15
|
+
type Names<Kind extends "query" | "mutation" | "action"> = {
|
|
16
|
+
[Name in keyof Server]: Server[Name] extends RegisteredFunction<Kind, unknown, unknown, false> ? Name : never;
|
|
17
|
+
}[keyof Server] &
|
|
18
|
+
string;
|
|
19
|
+
|
|
20
|
+
type Shape<Name> = Name extends keyof Server
|
|
21
|
+
? Server[Name] extends RegisteredFunction<"query" | "mutation" | "action", infer A, infer R, boolean>
|
|
22
|
+
? { args: A; result: R }
|
|
23
|
+
: never
|
|
24
|
+
: never;
|
|
25
|
+
|
|
26
|
+
type ArgsOf<Name> = Shape<Name>["args"];
|
|
27
|
+
|
|
28
|
+
type OptionalArgs<Name> = {} extends ArgsOf<Name> ? [args?: ArgsOf<Name>] : [args: ArgsOf<Name>];
|
|
29
|
+
|
|
30
|
+
const YeetContext = createContext<YeetClient | null>(null);
|
|
31
|
+
|
|
32
|
+
/** The app's WebSocket: at `url`, `/sync` on it when it is the app's address, or else on the page's host. */
|
|
33
|
+
function socketUrl(url: string | undefined) {
|
|
34
|
+
if (url === undefined) {
|
|
35
|
+
const location = page()?.location;
|
|
36
|
+
if (!location) throw new Error("pass YeetProvider the app's address as url, such as https://wovies.yeet.tourik.dev");
|
|
37
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
38
|
+
return `${protocol}//${location.host}/sync`;
|
|
39
|
+
}
|
|
40
|
+
if (!/^https?:\/\//.test(url)) return url;
|
|
41
|
+
return `${url.replace(/^http/, "ws").replace(/\/+$/, "")}/sync`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Keeps one WebSocket to the app open while mounted, at `/sync` on the page's own host unless
|
|
46
|
+
* `url` is given. A phone app gives the app's address as `url`, and the `token` from
|
|
47
|
+
* `yeet/sign-in`; a new token opens a new connection.
|
|
48
|
+
*/
|
|
49
|
+
export function YeetProvider({ url, token, children }: { url?: string; token?: string; children?: ReactNode }) {
|
|
50
|
+
const client = useMemo(() => new YeetClient(socketUrl(url), token), [url, token]);
|
|
51
|
+
useAuthState(client);
|
|
52
|
+
return createElement(YeetContext.Provider, { value: client }, children);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function useAuthState(client: YeetClient) {
|
|
56
|
+
const subscribe = useCallback((onChange: () => void) => client.onAuthChange(onChange), [client]);
|
|
57
|
+
return useSyncExternalStore(subscribe, () => client.authState());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function useClient() {
|
|
61
|
+
const client = useContext(YeetContext);
|
|
62
|
+
if (client === null) throw new Error("wrap your app in <YeetProvider>");
|
|
63
|
+
return client;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The query's current result, kept up to date; `undefined` until the first result arrives. */
|
|
67
|
+
export function useQuery<Name extends Names<"query">>(name: Name, ...[args]: OptionalArgs<Name>) {
|
|
68
|
+
const client = useClient();
|
|
69
|
+
const queryArgs = (args ?? {}) as Args;
|
|
70
|
+
const key = queryKey(name, queryArgs);
|
|
71
|
+
// Re-subscribes only when the key changes, not when the caller passes a new but equal args object.
|
|
72
|
+
const subscribe = useCallback((onChange: () => void) => client.subscribe(name, queryArgs, onChange), [client, key]);
|
|
73
|
+
const state = useSyncExternalStore(subscribe, () => client.queryState(name, queryArgs));
|
|
74
|
+
if (state === undefined) return undefined;
|
|
75
|
+
if ("error" in state) throw state.error;
|
|
76
|
+
return state.value as Shape<Name>["result"];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useMutation<Name extends Names<"mutation">>(name: Name) {
|
|
80
|
+
const client = useClient();
|
|
81
|
+
return useCallback(
|
|
82
|
+
(...[args]: OptionalArgs<Name>) => client.mutation(name, (args ?? {}) as Args) as Promise<Shape<Name>["result"]>,
|
|
83
|
+
[client, name],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useAction<Name extends Names<"action">>(name: Name) {
|
|
88
|
+
const client = useClient();
|
|
89
|
+
return useCallback(
|
|
90
|
+
(...[args]: OptionalArgs<Name>) => client.action(name, (args ?? {}) as Args) as Promise<Shape<Name>["result"]>,
|
|
91
|
+
[client, name],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A function that replaces the page's WebSocket with a new one, for a page that resumes after the
|
|
97
|
+
* device slept, when the old connection may be dead without knowing it.
|
|
98
|
+
*/
|
|
99
|
+
export function useReconnect() {
|
|
100
|
+
const client = useClient();
|
|
101
|
+
return useCallback(() => client.reconnect(), [client]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Who the server signs this page in as; `isLoading` until the server says. */
|
|
105
|
+
export function useAuth() {
|
|
106
|
+
return useAuthState(useClient());
|
|
107
|
+
}
|
package/server.d.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
export type Value = null | boolean | number | string | Value[] | { [field: string]: Value | undefined };
|
|
2
|
+
|
|
3
|
+
export type Id<Table extends string> = string & { readonly __table: Table };
|
|
4
|
+
|
|
5
|
+
export interface Validator<T> {
|
|
6
|
+
readonly kind: string;
|
|
7
|
+
readonly __type?: T;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OptionalValidator<T> extends Validator<T | undefined> {
|
|
11
|
+
readonly kind: "optional";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Infer<V> = V extends Validator<infer T> ? T : never;
|
|
15
|
+
|
|
16
|
+
type Fields = Record<string, Validator<unknown>>;
|
|
17
|
+
|
|
18
|
+
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends OptionalValidator<unknown> ? K : never }[keyof F];
|
|
19
|
+
|
|
20
|
+
type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
21
|
+
|
|
22
|
+
export type ObjectType<F extends Fields> = Simplify<
|
|
23
|
+
{ [K in Exclude<keyof F, OptionalKeys<F>>]: Infer<F[K]> } & { [K in OptionalKeys<F>]?: Infer<F[K]> }
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
export declare const v: {
|
|
27
|
+
string(): Validator<string>;
|
|
28
|
+
number(): Validator<number>;
|
|
29
|
+
boolean(): Validator<boolean>;
|
|
30
|
+
null(): Validator<null>;
|
|
31
|
+
id<Table extends string>(table: Table): Validator<Id<Table>>;
|
|
32
|
+
literal<const T extends string | number | boolean>(value: T): Validator<T>;
|
|
33
|
+
array<T>(item: Validator<T>): Validator<T[]>;
|
|
34
|
+
object<F extends Fields>(fields: F): Validator<ObjectType<F>>;
|
|
35
|
+
optional<T>(inner: Validator<T>): OptionalValidator<T>;
|
|
36
|
+
union<Members extends Validator<unknown>[]>(...members: Members): Validator<Infer<Members[number]>>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface TableDefinition {
|
|
40
|
+
index(name: string, fields: string[]): TableDefinition;
|
|
41
|
+
searchIndex(name: string, options: { searchField: string; filterFields?: string[] }): TableDefinition;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export declare function defineTable(fields: Fields): TableDefinition;
|
|
45
|
+
|
|
46
|
+
export interface SchemaDefinition {
|
|
47
|
+
readonly tables: Record<string, TableDefinition>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export declare function defineSchema(tables: Record<string, TableDefinition>): SchemaDefinition;
|
|
51
|
+
|
|
52
|
+
export interface Document {
|
|
53
|
+
readonly _id: Id<string>;
|
|
54
|
+
readonly _creationTime: number;
|
|
55
|
+
readonly [field: string]: Value | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type RangeValue = string | number | boolean | null;
|
|
59
|
+
|
|
60
|
+
export interface IndexRange {
|
|
61
|
+
eq(field: string, value: RangeValue): IndexRange;
|
|
62
|
+
gt(field: string, value: RangeValue): IndexRange;
|
|
63
|
+
gte(field: string, value: RangeValue): IndexRange;
|
|
64
|
+
lt(field: string, value: RangeValue): IndexRange;
|
|
65
|
+
lte(field: string, value: RangeValue): IndexRange;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SearchFilter {
|
|
69
|
+
search(field: string, text: string): SearchFilter;
|
|
70
|
+
eq(field: string, value: RangeValue): SearchFilter;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Documents with a word starting with each word of the search text, best match first. */
|
|
74
|
+
export interface SearchQuery {
|
|
75
|
+
collect(): Promise<Document[]>;
|
|
76
|
+
take(n: number): Promise<Document[]>;
|
|
77
|
+
first(): Promise<Document | null>;
|
|
78
|
+
unique(): Promise<Document | null>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface Query {
|
|
82
|
+
withIndex(index: string, range?: (q: IndexRange) => IndexRange): Query;
|
|
83
|
+
withSearchIndex(index: string, search: (q: SearchFilter) => SearchFilter): SearchQuery;
|
|
84
|
+
order(order: "asc" | "desc"): Query;
|
|
85
|
+
collect(): Promise<Document[]>;
|
|
86
|
+
take(n: number): Promise<Document[]>;
|
|
87
|
+
first(): Promise<Document | null>;
|
|
88
|
+
unique(): Promise<Document | null>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DatabaseReader {
|
|
92
|
+
query(table: string): Query;
|
|
93
|
+
get(id: Id<string>): Promise<Document | null>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DatabaseWriter extends DatabaseReader {
|
|
97
|
+
insert(table: string, value: Record<string, Value>): Promise<Id<string>>;
|
|
98
|
+
patch(id: Id<string>, fields: Record<string, Value | undefined>): Promise<void>;
|
|
99
|
+
replace(id: Id<string>, value: Record<string, Value>): Promise<void>;
|
|
100
|
+
delete(id: Id<string>): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Auth {
|
|
104
|
+
readonly userId: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface QueryCtx {
|
|
108
|
+
readonly db: DatabaseReader;
|
|
109
|
+
readonly auth: Auth;
|
|
110
|
+
readonly now: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface MutationCtx {
|
|
114
|
+
readonly db: DatabaseWriter;
|
|
115
|
+
readonly auth: Auth;
|
|
116
|
+
readonly now: number;
|
|
117
|
+
/** Jobs scheduled here exist only if the mutation commits. */
|
|
118
|
+
readonly scheduler: Scheduler;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
declare const registered: unique symbol;
|
|
122
|
+
|
|
123
|
+
type Kind = "query" | "mutation" | "action";
|
|
124
|
+
|
|
125
|
+
/** A function the server code exports; an internal one only actions and `yeet run` can call. */
|
|
126
|
+
export interface RegisteredFunction<K extends Kind, Args, Result, Internal extends boolean = boolean> {
|
|
127
|
+
readonly [registered]: { kind: K; args: Args; result: Result; internal: Internal };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type Handler<Ctx, Args, Result> = (ctx: Ctx, args: Args) => Result | Promise<Result>;
|
|
131
|
+
|
|
132
|
+
type Run<K extends Kind> = <Args, Result>(
|
|
133
|
+
fn: RegisteredFunction<K, Args, Result>,
|
|
134
|
+
...args: {} extends Args ? [args?: Args] : [args: Args]
|
|
135
|
+
) => Promise<Awaited<Result>>;
|
|
136
|
+
|
|
137
|
+
type Schedule = <Args, Result>(
|
|
138
|
+
time: number,
|
|
139
|
+
fn: RegisteredFunction<"mutation" | "action", Args, Result>,
|
|
140
|
+
...args: {} extends Args ? [args?: Args] : [args: Args]
|
|
141
|
+
) => Promise<string>;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Runs an exported mutation or action later, as nobody (`ctx.auth.userId` is null), on `yeet dev`
|
|
145
|
+
* and on the live app; previews keep their jobs without running them.
|
|
146
|
+
*/
|
|
147
|
+
export interface Scheduler {
|
|
148
|
+
/** Runs `fn` in `ms` milliseconds and returns the job's id. */
|
|
149
|
+
readonly runAfter: Schedule;
|
|
150
|
+
/** Runs `fn` at `time`, in milliseconds since 1970, and returns the job's id. */
|
|
151
|
+
readonly runAt: Schedule;
|
|
152
|
+
cancel(id: string): Promise<void>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* An action's context: it reads and writes only through queries and mutations. Inside actions,
|
|
157
|
+
* `Date` and `Math.random` are the real ones, and `setTimeout` exists.
|
|
158
|
+
*/
|
|
159
|
+
export interface ActionCtx {
|
|
160
|
+
readonly auth: Auth;
|
|
161
|
+
readonly runQuery: Run<"query">;
|
|
162
|
+
readonly runMutation: Run<"mutation">;
|
|
163
|
+
readonly scheduler: Scheduler;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type CronArgs<Args> = {} extends Args ? [args?: Args] : [args: Args];
|
|
167
|
+
|
|
168
|
+
type DayOfWeek = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Mutations and actions that repeat on a timetable, as nobody. Export the object that `cronJobs()`
|
|
172
|
+
* returns from `server/index.ts`. A cron missed while the app was stopped runs once when it starts.
|
|
173
|
+
*/
|
|
174
|
+
export interface Crons {
|
|
175
|
+
interval<Args>(
|
|
176
|
+
name: string,
|
|
177
|
+
every: { seconds?: number; minutes?: number; hours?: number },
|
|
178
|
+
fn: RegisteredFunction<"mutation" | "action", Args, unknown>,
|
|
179
|
+
...args: CronArgs<Args>
|
|
180
|
+
): Crons;
|
|
181
|
+
hourly<Args>(
|
|
182
|
+
name: string,
|
|
183
|
+
at: { minuteUTC: number },
|
|
184
|
+
fn: RegisteredFunction<"mutation" | "action", Args, unknown>,
|
|
185
|
+
...args: CronArgs<Args>
|
|
186
|
+
): Crons;
|
|
187
|
+
daily<Args>(
|
|
188
|
+
name: string,
|
|
189
|
+
at: { hourUTC: number; minuteUTC: number },
|
|
190
|
+
fn: RegisteredFunction<"mutation" | "action", Args, unknown>,
|
|
191
|
+
...args: CronArgs<Args>
|
|
192
|
+
): Crons;
|
|
193
|
+
weekly<Args>(
|
|
194
|
+
name: string,
|
|
195
|
+
at: { dayOfWeek: DayOfWeek; hourUTC: number; minuteUTC: number },
|
|
196
|
+
fn: RegisteredFunction<"mutation" | "action", Args, unknown>,
|
|
197
|
+
...args: CronArgs<Args>
|
|
198
|
+
): Crons;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export declare function cronJobs(): Crons;
|
|
202
|
+
|
|
203
|
+
interface Define<K extends Kind, Ctx, Internal extends boolean> {
|
|
204
|
+
<A extends Fields, Result>(definition: {
|
|
205
|
+
args: A;
|
|
206
|
+
handler: Handler<Ctx, ObjectType<A>, Result>;
|
|
207
|
+
}): RegisteredFunction<K, ObjectType<A>, Result, Internal>;
|
|
208
|
+
<Result>(definition: {
|
|
209
|
+
handler: Handler<Ctx, Record<string, never>, Result>;
|
|
210
|
+
}): RegisteredFunction<K, Record<string, never>, Result, Internal>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export declare const query: Define<"query", QueryCtx, false>;
|
|
214
|
+
export declare const mutation: Define<"mutation", MutationCtx, false>;
|
|
215
|
+
/** Like a mutation, but may wait on the network; it is not one transaction. */
|
|
216
|
+
export declare const action: Define<"action", ActionCtx, false>;
|
|
217
|
+
export declare const internalQuery: Define<"query", QueryCtx, true>;
|
|
218
|
+
export declare const internalMutation: Define<"mutation", MutationCtx, true>;
|
|
219
|
+
export declare const internalAction: Define<"action", ActionCtx, true>;
|
|
220
|
+
|
|
221
|
+
declare const endpoint: unique symbol;
|
|
222
|
+
|
|
223
|
+
/** An endpoint's handler: it runs like an action and answers with a `Response`. */
|
|
224
|
+
export interface HttpAction {
|
|
225
|
+
readonly [endpoint]: true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export declare function httpAction(handler: (ctx: ActionCtx, request: Request) => Response | Promise<Response>): HttpAction;
|
|
229
|
+
|
|
230
|
+
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
231
|
+
|
|
232
|
+
type Route = ({ path: `/api/${string}` } | { pathPrefix: `/api/${string}` }) & { method: HttpMethod; handler: HttpAction };
|
|
233
|
+
|
|
234
|
+
/** The app's endpoints; export it, as in `export const http = httpRouter()`. */
|
|
235
|
+
export interface HttpRouter {
|
|
236
|
+
route(route: Route): void;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export declare function httpRouter(): HttpRouter;
|
|
240
|
+
|
|
241
|
+
declare global {
|
|
242
|
+
/** Inside actions only: the app's secrets, from `yeet secret set`, or `.env` on `yeet dev`. */
|
|
243
|
+
const process: { readonly env: Readonly<Record<string, string | undefined>> };
|
|
244
|
+
}
|
package/sign-in.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signs a phone app in through the system browser. Open `url` from `startSignIn` in the browser,
|
|
3
|
+
* for example with Expo's `WebBrowser.openAuthSessionAsync(url, redirect)`, then pass the address
|
|
4
|
+
* the browser came back to to `finish`. List `redirect` in `yeet.json`'s `"redirects"`.
|
|
5
|
+
*/
|
|
6
|
+
export interface SignIn {
|
|
7
|
+
readonly url: string;
|
|
8
|
+
finish(redirectedUrl: string): Promise<{ token: string; userId: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const K = new Uint32Array([
|
|
12
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
|
|
13
|
+
0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
|
14
|
+
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
|
|
15
|
+
0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
16
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
|
|
17
|
+
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
|
18
|
+
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
|
|
19
|
+
0xc67178f2,
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** SHA-256 of ASCII text, since Hermes, React Native's engine, has no Web Crypto. */
|
|
23
|
+
function sha256(text: string) {
|
|
24
|
+
const length = text.length;
|
|
25
|
+
const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
|
|
26
|
+
for (let i = 0; i < length; i++) padded[i] = text.charCodeAt(i);
|
|
27
|
+
padded[length] = 0x80;
|
|
28
|
+
new DataView(padded.buffer).setUint32(padded.length - 4, length * 8);
|
|
29
|
+
const hash = new Uint32Array([
|
|
30
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
31
|
+
]);
|
|
32
|
+
const words = new Uint32Array(64);
|
|
33
|
+
const view = new DataView(padded.buffer);
|
|
34
|
+
const rotate = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
|
35
|
+
for (let block = 0; block < padded.length; block += 64) {
|
|
36
|
+
for (let i = 0; i < 16; i++) words[i] = view.getUint32(block + i * 4);
|
|
37
|
+
for (let i = 16; i < 64; i++) {
|
|
38
|
+
const s0 = rotate(words[i - 15], 7) ^ rotate(words[i - 15], 18) ^ (words[i - 15] >>> 3);
|
|
39
|
+
const s1 = rotate(words[i - 2], 17) ^ rotate(words[i - 2], 19) ^ (words[i - 2] >>> 10);
|
|
40
|
+
words[i] = words[i - 16] + s0 + words[i - 7] + s1;
|
|
41
|
+
}
|
|
42
|
+
let [a, b, c, d, e, f, g, h] = hash;
|
|
43
|
+
for (let i = 0; i < 64; i++) {
|
|
44
|
+
const t1 = h + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + words[i];
|
|
45
|
+
const t2 = (rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
|
|
46
|
+
[h, g, f, e, d, c, b, a] = [g, f, e, (d + t1) >>> 0, c, b, a, (t1 + t2) >>> 0];
|
|
47
|
+
}
|
|
48
|
+
[a, b, c, d, e, f, g, h].forEach((value, i) => (hash[i] = hash[i] + value));
|
|
49
|
+
}
|
|
50
|
+
const bytes = new Uint8Array(32);
|
|
51
|
+
hash.forEach((word, i) => new DataView(bytes.buffer).setUint32(i * 4, word));
|
|
52
|
+
return bytes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
56
|
+
|
|
57
|
+
function base64url(bytes: Uint8Array) {
|
|
58
|
+
let text = "";
|
|
59
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
60
|
+
const chunk = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
61
|
+
const characters = Math.ceil(((Math.min(3, bytes.length - i)) * 8) / 6);
|
|
62
|
+
for (let j = 0; j < characters; j++) text += BASE64URL[(chunk >> (18 - 6 * j)) & 63];
|
|
63
|
+
}
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function randomVerifier() {
|
|
68
|
+
const crypto = (globalThis as { crypto?: { getRandomValues?(array: Uint8Array): Uint8Array } }).crypto;
|
|
69
|
+
if (typeof crypto?.getRandomValues !== "function") {
|
|
70
|
+
throw new Error("crypto.getRandomValues is missing; in React Native, import \"react-native-get-random-values\" first");
|
|
71
|
+
}
|
|
72
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Starts a sign-in to the app at `appUrl`, such as `https://wovies.yeet.tourik.dev`, that ends at `redirect`. */
|
|
76
|
+
export function startSignIn(appUrl: string, redirect: string): SignIn {
|
|
77
|
+
const app = appUrl.replace(/\/+$/, "");
|
|
78
|
+
const verifier = randomVerifier();
|
|
79
|
+
const challenge = base64url(sha256(verifier));
|
|
80
|
+
return {
|
|
81
|
+
url: `${app}/_yeet/login?redirect=${encodeURIComponent(redirect)}&challenge=${challenge}`,
|
|
82
|
+
async finish(redirectedUrl) {
|
|
83
|
+
const code = /[?&]code=([^&#]+)/.exec(redirectedUrl)?.[1];
|
|
84
|
+
if (code === undefined) throw new Error(`${redirectedUrl} has no code; the sign-in did not finish`);
|
|
85
|
+
const response = await fetch(`${app}/_yeet/token`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ code: decodeURIComponent(code), verifier }),
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok) throw new Error(await response.text());
|
|
91
|
+
return (await response.json()) as { token: string; userId: string };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Ends the token's session, so it signs nobody in any more. */
|
|
97
|
+
export async function signOut(appUrl: string, token: string) {
|
|
98
|
+
const response = await fetch(`${appUrl.replace(/\/+$/, "")}/_yeet/logout`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { authorization: `Bearer ${token}` },
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) throw new Error(await response.text());
|
|
103
|
+
}
|