lecodes-sdk 2.0.4 → 2.0.5
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/README.md +104 -76
- package/dist/global.d.ts +2 -5
- package/dist/host.d.ts +3 -0
- package/dist/types/inject.d.ts +3 -3
- package/dist/types/runtime/device.d.ts +7 -0
- package/dist/types/runtime/rpc.d.ts +11 -17
- package/dist/types/runtime/wire.d.ts +53 -0
- package/dist/types/server/auth/api.d.ts +42 -0
- package/dist/types/server/auth/appConfig.d.ts +1 -5
- package/dist/types/server/auth/models.d.ts +119 -70
- package/dist/types/server/auth/types.d.ts +19 -43
- package/dist/types/server/channel.d.ts +57 -19
- package/dist/types/server/context.d.ts +2 -2
- package/dist/types/server/db/defineDb.d.ts +10 -0
- package/dist/types/server/db/index.d.ts +1 -1
- package/dist/types/server/db/types.d.ts +76 -6
- package/dist/types/server/inject.d.ts +0 -1
- package/dist/types/ui/UINode.d.ts +19 -5
- package/dist/types/ui/UIScreen.d.ts +1 -0
- package/dist/types/ui/UITabs.d.ts +8 -6
- package/dist/types/ui/theme.d.ts +48 -13
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +3 -2
- package/prompts/README.md +1 -1
- package/prompts/design.md +19 -19
- package/prompts/dist/2d-game.md +45 -31
- package/prompts/dist/3d-app.md +45 -31
- package/prompts/dist/ar-app.md +45 -31
- package/prompts/dist/design.md +25 -24
- package/prompts/dist/ui-app.md +45 -31
- package/prompts/ui-design.md +6 -5
- package/prompts/ui.md +25 -22
- package/src/bridges/device.d.ts +9 -0
- package/src/bridges/tree.d.ts +5 -0
- package/src/chisel.ts +1 -1
- package/src/compile/bundler.ts +6 -0
- package/src/compile/compileProject.ts +3 -1
- package/src/compile/index.ts +3 -1
- package/src/compile/serverSplit.ts +58 -11
- package/src/compile/serverTypes.ts +189 -8
- package/src/host.d.ts +3 -0
- package/src/inject.ts +6 -6
- package/src/runtime/device.ts +12 -0
- package/src/runtime/rpc.ts +101 -40
- package/src/runtime/wire.ts +35 -0
- package/src/server/auth/api.ts +94 -0
- package/src/server/auth/appConfig.ts +2 -3
- package/src/server/auth/host.ts +244 -174
- package/src/server/auth/models.ts +45 -62
- package/src/server/auth/types.ts +19 -34
- package/src/server/channel.ts +97 -29
- package/src/server/channelHub.ts +153 -0
- package/src/server/context.ts +2 -2
- package/src/server/db/defineDb.ts +96 -36
- package/src/server/db/index.ts +1 -1
- package/src/server/db/types.ts +76 -8
- package/src/server/host.ts +25 -10
- package/src/server/inject.ts +2 -2
- package/src/server/runtime.ts +34 -12
- package/src/ui/UINode.ts +22 -5
- package/src/ui/UIScreen.ts +5 -0
- package/src/ui/UITabs.ts +19 -17
- package/src/ui/styleColor.ts +10 -1
- package/src/ui/theme.ts +96 -41
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +1 -0
- package/dist/types/plugins/oauth.d.ts +0 -25
- package/dist/types/server/auth/global.d.ts +0 -56
- package/src/plugins/oauth.ts +0 -61
- package/src/server/auth/global.ts +0 -80
- package/tests/helpers/memoryMarci.ts +0 -124
|
@@ -1,83 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
3
|
-
* (docs/backend-plan.md §3.4 / decision 10). Both return ordinary `model(...)`s: the developer registers
|
|
4
|
-
* them in `defineDb({ User, Session, … })` under exactly those keys (other models relate to them with
|
|
5
|
-
* `t.one("User")` / `t.one("Session")`), and `defineDb` adds `Session` itself when it isn't declared —
|
|
6
|
-
* a device session exists from the first request, before any login, so every project db has one.
|
|
2
|
+
* The two models sign-in adds to a database — `defineDb({...}).withAuth({ model: "User" })`:
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Session one device: the hash of its token, and the user it is signed in as (none = a guest)
|
|
5
|
+
* Identity one way a user signs in: `email:<address>` or `login:<name>` (with the password's hash
|
|
6
|
+
* when one is set) — several per user.
|
|
7
|
+
*
|
|
8
|
+
* They are the PLATFORM's: a project never declares them and never writes to them — it may refer to a
|
|
9
|
+
* session (`t.one("Session")`: a guest's cart) and read both through `db.auth.sessions` /
|
|
10
|
+
* `db.auth.identities`, which leave the secrets out. The user model is the project's own, an ordinary
|
|
11
|
+
* `model({...})`: the platform creates a row of it the first time someone signs in and knows nothing
|
|
12
|
+
* of it but the id.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
|
-
import { t
|
|
13
|
-
import type { Fields, Model } from "../db/types"
|
|
14
|
-
import { app, type AppConfig, type AuthProvider } from "./appConfig"
|
|
15
|
+
import { t } from "../db/fields"
|
|
15
16
|
|
|
16
|
-
export type { AppConfig
|
|
17
|
+
export type { AppConfig } from "./appConfig"
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export const appConfig = (): AppConfig => (Object.keys(app).length ? app : seam.__lecodesApp ?? {})
|
|
21
|
-
export const enabledProviders = (): AuthProvider[] => appConfig().auth?.providers ?? []
|
|
19
|
+
/** What an `Identity` row is of: an email, or a login (a name with no address behind it). */
|
|
20
|
+
export type IdentityProvider = "email" | "login"
|
|
22
21
|
|
|
23
|
-
/** The model name the auth runtime binds to. */
|
|
24
|
-
export const AUTH_USER_MODEL = "User"
|
|
25
22
|
export const AUTH_SESSION_MODEL = "Session"
|
|
23
|
+
export const AUTH_IDENTITY_MODEL = "Identity"
|
|
26
24
|
|
|
27
|
-
/**
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/** Built-in `User` fields (what `auth.user` is typed with). */
|
|
34
|
-
export const userFields = () => ({
|
|
35
|
-
email: t.string().optional().unique(),
|
|
36
|
-
name: t.string().optional(),
|
|
37
|
-
avatar: t.string().optional(),
|
|
38
|
-
/** Plain string column, `"user"` by default — `auth.requireUser({ role: "admin" })` is field equality. */
|
|
39
|
-
role: t.string().default("user"),
|
|
40
|
-
createdAt: t.date().default("now"),
|
|
25
|
+
/** A session as a project reads it (`db.auth.sessions`, a `t.one("Session")` relation). */
|
|
26
|
+
export const sessionPublicFields = () => ({
|
|
27
|
+
createdAt: t.date().default("now"),
|
|
28
|
+
lastSeenAt: t.date().default("now"),
|
|
29
|
+
revoked: t.bool().default(false),
|
|
41
30
|
})
|
|
42
|
-
export type
|
|
31
|
+
export type SessionPublicFields = ReturnType<typeof sessionPublicFields>
|
|
43
32
|
|
|
44
|
-
/**
|
|
45
|
-
const
|
|
46
|
-
const out: Record<string, Field<any, any>> = {}
|
|
47
|
-
for (const p of enabledProviders()) if (p !== "email") out[`${p}Id`] = t.string().optional().unique()
|
|
48
|
-
return out
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Built-in `Session` fields. `user` is added by `defineDb` when a `User` model exists (schema-time, so a
|
|
52
|
-
* db without an auth user model has sessions without a user column). */
|
|
53
|
-
export const sessionFields = () => ({
|
|
33
|
+
/** The whole `Session`; `user` points at the model `withAuth` named. */
|
|
34
|
+
export const sessionFields = (userModel: string) => ({
|
|
54
35
|
/** sha256 of the bearer token the device holds — the token itself is never stored. */
|
|
55
36
|
tokenHash: t.string().unique(),
|
|
56
|
-
|
|
57
|
-
lastSeenAt: t.date().default("now"),
|
|
58
|
-
revoked: t.bool().default(false),
|
|
37
|
+
...sessionPublicFields(),
|
|
59
38
|
// email-code sign-in state lives on the requesting session (no code table)
|
|
60
39
|
pendingEmail: t.string().optional(),
|
|
61
40
|
codeHash: t.string().optional(),
|
|
62
41
|
codeExpires: t.date().optional(),
|
|
63
42
|
codeAttempts: t.int().default(0),
|
|
43
|
+
user: t.one(userModel).optional().onDelete("cascade"),
|
|
64
44
|
})
|
|
65
|
-
export type SessionFields = ReturnType<typeof sessionFields> & { user: ReturnType<typeof sessionUserField> }
|
|
66
|
-
export const sessionUserField = () => t.one(AUTH_USER_MODEL).optional().onDelete("cascade")
|
|
67
|
-
|
|
68
|
-
// ───────────────────────────── factories ─────────────────────────────
|
|
69
45
|
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
46
|
+
/** A sign-in method as a project reads it (`db.auth.identities`) — no hash. */
|
|
47
|
+
export const identityPublicFields = () => ({
|
|
48
|
+
/** `"email" | "login"` */
|
|
49
|
+
provider: t.string().index(),
|
|
50
|
+
/** What the person types to sign in: the address, or the login. */
|
|
51
|
+
subject: t.string().index(),
|
|
52
|
+
/** When the subject was proven (a code). Unset = a password sign-up whose address wasn't confirmed. */
|
|
53
|
+
verifiedAt: t.date().optional(),
|
|
54
|
+
createdAt: t.date().default("now"),
|
|
55
|
+
})
|
|
56
|
+
export type IdentityPublicFields = ReturnType<typeof identityPublicFields>
|
|
81
57
|
|
|
82
|
-
|
|
83
|
-
|
|
58
|
+
/** The whole `Identity`. */
|
|
59
|
+
export const identityFields = (userModel: string) => ({
|
|
60
|
+
user: t.one(userModel).onDelete("cascade"),
|
|
61
|
+
...identityPublicFields(),
|
|
62
|
+
/** `<provider>:<subject>` — marcidb has no composite @unique, this is the one-row-per-identity guarantee. */
|
|
63
|
+
key: t.string().unique(),
|
|
64
|
+
/** scrypt hash of the password (`email` / `login` identities). */
|
|
65
|
+
hash: t.string().optional(),
|
|
66
|
+
})
|
package/src/server/auth/types.ts
CHANGED
|
@@ -1,50 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared shapes of the auth layer
|
|
3
|
-
* - the BUNDLE side (`auth
|
|
4
|
-
* - the HOST side (./host.ts) — sessions,
|
|
5
|
-
* project's db handle
|
|
2
|
+
* Shared shapes of the auth layer. Two sides meet through them:
|
|
3
|
+
* - the BUNDLE side (`db.auth`, ./api.ts) — what `*.server.ts` code calls;
|
|
4
|
+
* - the HOST side (./host.ts) — sessions, passwords and email codes, run by the runner with the
|
|
5
|
+
* project's db handle. Installed on `globalThis.__lecodesAuth`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { RequestContext } from "../context"
|
|
9
|
-
import type { AuthProvider } from "./models"
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
email: string | null
|
|
15
|
-
name: string | null
|
|
16
|
-
avatar: string | null
|
|
17
|
-
role: string
|
|
18
|
-
createdAt: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Per-request auth state, resolved by the host before the endpoint runs (`RequestContext.auth`). */
|
|
10
|
+
/** Per-request auth state, resolved by the host before the endpoint runs (`RequestContext.auth`): the
|
|
11
|
+
* device's session and the id of the user it is signed in as. Nothing of the user's row — a project
|
|
12
|
+
* reads that itself (`db.auth.user()`). */
|
|
22
13
|
export type AuthState = {
|
|
23
14
|
sessionId: number
|
|
24
15
|
userId: number | null
|
|
25
|
-
user: AuthUser | null
|
|
26
16
|
/** A token to hand back to the client in this response (a new guest session, or a rotation). */
|
|
27
17
|
issuedToken?: string
|
|
28
18
|
}
|
|
29
19
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
/** A credential a NATIVE provider SDK produced in the app (client plugin `OAuth.signIn`): an access token
|
|
33
|
-
* (Yandex, VK ID) or an OpenID identity token (Google, Apple). Verified server-side, never trusted as-is. */
|
|
34
|
-
export type OAuthCredential = { accessToken?: string, idToken?: string, name?: string | null, email?: string | null }
|
|
35
|
-
|
|
36
|
-
/** Host operations the bundle's `auth` global forwards to (all request-scoped through `ctx`). */
|
|
20
|
+
/** Host operations `db.auth` forwards to (all request-scoped through `ctx`). A sign-in resolves once the
|
|
21
|
+
* session is bound; who it is bound to is read through `db.auth.user()`. */
|
|
37
22
|
export type AuthOps = {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
signIn(ctx: RequestContext, userId: number): Promise<AuthUser>
|
|
23
|
+
/** `data` = the fields of the new user's row (the project's own model). */
|
|
24
|
+
signUpWithPassword(ctx: RequestContext, login: string, password: string, data?: Record<string, unknown>): Promise<void>
|
|
25
|
+
signInWithPassword(ctx: RequestContext, login: string, password: string): Promise<void>
|
|
26
|
+
setPassword(ctx: RequestContext, password: string): Promise<void>
|
|
27
|
+
sendCode(ctx: RequestContext, email: string): Promise<void>
|
|
28
|
+
signInWithCode(ctx: RequestContext, email: string, code: string): Promise<void>
|
|
29
|
+
signIn(ctx: RequestContext, userId: number): Promise<void>
|
|
47
30
|
signOut(ctx: RequestContext): Promise<void>
|
|
48
|
-
/**
|
|
31
|
+
/** The sessions of the current user (or just this device's, when signed out), as a query. */
|
|
49
32
|
sessions(ctx: RequestContext): any
|
|
33
|
+
/** The current user's sign-in methods, as a query — the hash is not in it. */
|
|
34
|
+
identities(ctx: RequestContext): any
|
|
50
35
|
}
|
package/src/server/channel.ts
CHANGED
|
@@ -1,56 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Channels: the server's messages to the app. A server function is a call from the app to the
|
|
3
|
+
* server; a channel is the other direction, typed by the same export.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* export const
|
|
6
|
-
*
|
|
5
|
+
* export const postsChannel = channel<Post>() // to everyone listening
|
|
6
|
+
* export const noticesChannel = channel<Notice>() // to each their own
|
|
7
|
+
* .groupBy(async () => (await db.auth.requireUser().select({ id: true })).id)
|
|
8
|
+
*
|
|
9
|
+
* postsChannel.publish(post) // server
|
|
10
|
+
* noticesChannel.publish(userId, notice)
|
|
11
|
+
*
|
|
12
|
+
* postsChannel.subscribe(post => …) // app
|
|
13
|
+
* noticesChannel.subscribe(notice => …)
|
|
14
|
+
*
|
|
15
|
+
* `groupBy` runs on the server when an app subscribes, with what the app passed to `subscribe`
|
|
16
|
+
* before the handler, and answers the group that subscriber is in; `publish(group, message)` reaches
|
|
17
|
+
* that group. `authorize` only decides who may listen. Both refuse by throwing (`ApiError`), and
|
|
18
|
+
* both run in the request scope of the subscriber, so `db.auth` works in them.
|
|
7
19
|
*
|
|
8
20
|
* A channel's identity is its export (`<path>#<name>`), assigned by the runtime when the bundle is
|
|
9
21
|
* loaded (`loadServerModules`); `publish` before that is an error. Delivery goes through the
|
|
10
|
-
* `globalThis.__lecodesPublish` seam the
|
|
11
|
-
* carries its own SDK copy).
|
|
22
|
+
* `globalThis.__lecodesPublish` seam the host installs (same reason as ./context.ts: the bundle
|
|
23
|
+
* carries its own SDK copy). In the app the export is another object altogether — the proxy
|
|
24
|
+
* `__channel` of src/runtime/rpc.ts — which is why `subscribe` here only throws.
|
|
12
25
|
*/
|
|
13
26
|
|
|
14
|
-
|
|
27
|
+
import type { ChannelGroup } from "../runtime/wire"
|
|
28
|
+
|
|
29
|
+
export type { ChannelGroup }
|
|
15
30
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
/** What `subscribe` answers: the subscription lives until `close()`. */
|
|
32
|
+
export type ChannelSubscription = { close(): void }
|
|
33
|
+
|
|
34
|
+
export type SubscribeOptions = {
|
|
35
|
+
/** The connection dropped and came back: what was published in between is lost — read the state again. */
|
|
36
|
+
reconnect?: () => void
|
|
37
|
+
/** The server refused the subscription (a hook threw): an `RpcError`, its `status` the hook's.
|
|
38
|
+
* Without it the refusal is logged. The subscription is not over: the server is asked again
|
|
39
|
+
* when the session changes, so this may be called more than once. */
|
|
40
|
+
error?: (e: Error & { readonly status: number }) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A channel every subscriber hears alike. */
|
|
44
|
+
export interface Channel<M> {
|
|
45
|
+
/** Who may listen: runs on the server at every subscription, throw to refuse. */
|
|
46
|
+
authorize(check: () => void | Promise<void>): Channel<M>
|
|
47
|
+
/** Split the subscribers: `group` runs on the server at every subscription — with what the app
|
|
48
|
+
* passed to `subscribe` — and answers the group of that subscriber. Throw to refuse. */
|
|
49
|
+
groupBy<K extends ChannelGroup, A extends unknown[] = []>(group: (...args: A) => K | Promise<K>): GroupedChannel<M, K, A>
|
|
50
|
+
/** Server: send `message` to every subscriber (at most once — nothing is kept for a socket that is away). */
|
|
51
|
+
publish(message: M): void
|
|
52
|
+
/** App: listen until `close()`. */
|
|
53
|
+
subscribe(handler: (message: M) => void, options?: SubscribeOptions): ChannelSubscription
|
|
19
54
|
}
|
|
20
55
|
|
|
21
|
-
|
|
22
|
-
|
|
56
|
+
/** A channel whose subscribers are in groups (`groupBy`). */
|
|
57
|
+
export interface GroupedChannel<M, K extends ChannelGroup, A extends unknown[]> {
|
|
58
|
+
/** Server: send `message` to the subscribers of `group`. */
|
|
59
|
+
publish(group: K, message: M): void
|
|
60
|
+
/** App: listen until `close()`; the arguments before the handler go to the channel's `groupBy`. */
|
|
61
|
+
subscribe(...args: [...args: A, handler: (message: M) => void, options?: SubscribeOptions]): ChannelSubscription
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Hook = (...args: any[]) => unknown
|
|
65
|
+
|
|
66
|
+
/** @internal what the host reads of a channel (src/server/runtime.ts) */
|
|
67
|
+
export type ChannelRecord = {
|
|
68
|
+
/** set by the runtime: `<path>#<export>` */
|
|
23
69
|
__id: string | null
|
|
24
|
-
/** @internal */
|
|
25
70
|
readonly __channel: true
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
publish<K extends keyof E & string>(topic: string, event: K, payload: E[K]): void
|
|
71
|
+
__authorize: Hook | null
|
|
72
|
+
__groupBy: Hook | null
|
|
29
73
|
}
|
|
30
74
|
|
|
31
|
-
type Publisher = (channelId: string,
|
|
75
|
+
type Publisher = (channelId: string, group: ChannelGroup | null, message: unknown) => void
|
|
32
76
|
const seam = globalThis as unknown as { __lecodesPublish?: Publisher }
|
|
33
77
|
|
|
34
|
-
/** Host hook (runner / test harness): where `publish` delivers to. */
|
|
78
|
+
/** Host hook (runner / local backend / test harness): where `publish` delivers to. */
|
|
35
79
|
export const setChannelPublisher = (publisher: Publisher | null) => {
|
|
36
80
|
if (publisher) seam.__lecodesPublish = publisher
|
|
37
81
|
else delete seam.__lecodesPublish
|
|
38
82
|
}
|
|
39
83
|
|
|
40
|
-
export const
|
|
41
|
-
|
|
84
|
+
export const isChannelGroup = (v: unknown): v is ChannelGroup =>
|
|
85
|
+
typeof v === "string" || (typeof v === "number" && Number.isFinite(v))
|
|
86
|
+
|
|
87
|
+
export const channel = <M = unknown>(): Channel<M> => {
|
|
88
|
+
const deliver = (group: ChannelGroup | null, message: unknown) => {
|
|
89
|
+
if (!ch.__id) throw new Error("channel.publish: the channel is not registered — export it from a *.server.ts file")
|
|
90
|
+
const publish = seam.__lecodesPublish
|
|
91
|
+
if (!publish) throw new Error("channel.publish: no host publisher installed")
|
|
92
|
+
publish(ch.__id, group, message)
|
|
93
|
+
}
|
|
94
|
+
const ch: ChannelRecord & Record<string, unknown> = {
|
|
42
95
|
__id: null,
|
|
43
96
|
__channel: true,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
97
|
+
__authorize: null,
|
|
98
|
+
__groupBy: null,
|
|
99
|
+
authorize(check: Hook) {
|
|
100
|
+
if (ch.__authorize) throw new Error("channel: authorize() is given once")
|
|
101
|
+
if (ch.__groupBy) throw new Error("channel: authorize() goes before groupBy() — or check inside groupBy")
|
|
102
|
+
ch.__authorize = check
|
|
103
|
+
return ch
|
|
104
|
+
},
|
|
105
|
+
groupBy(group: Hook) {
|
|
106
|
+
if (ch.__groupBy) throw new Error("channel: groupBy() is given once")
|
|
107
|
+
ch.__groupBy = group
|
|
108
|
+
return ch
|
|
109
|
+
},
|
|
110
|
+
publish(...args: unknown[]) {
|
|
111
|
+
if (!ch.__groupBy) return deliver(null, args[0])
|
|
112
|
+
if (args.length < 2) throw new Error("channel.publish: this channel has groups — publish(group, message)")
|
|
113
|
+
if (!isChannelGroup(args[0])) throw new Error(`channel.publish: a group is a string or a number, got ${args[0] === null ? "null" : typeof args[0]}`)
|
|
114
|
+
deliver(args[0], args[1])
|
|
115
|
+
},
|
|
116
|
+
subscribe() {
|
|
117
|
+
throw new Error("channel.subscribe: the app subscribes, the server publishes")
|
|
50
118
|
},
|
|
51
119
|
}
|
|
52
|
-
return ch
|
|
120
|
+
return ch as unknown as Channel<M>
|
|
53
121
|
}
|
|
54
122
|
|
|
55
|
-
export const isChannel = (v: unknown): v is
|
|
56
|
-
typeof v === "object" && v !== null && (v as
|
|
123
|
+
export const isChannel = (v: unknown): v is ChannelRecord =>
|
|
124
|
+
typeof v === "object" && v !== null && (v as ChannelRecord).__channel === true
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server end of the channel socket (../runtime/wire.ts), written once for every host that
|
|
3
|
+
* serves one: the runner (Bun's sockets, its native topics as the fan-out) and the local backend of
|
|
4
|
+
* `lecodes dev` (whatever ./serve gives it). A host hands over three things — how to send a frame
|
|
5
|
+
* to a socket, how to run a channel's hooks, and optionally its own fan-out — and feeds the hub the
|
|
6
|
+
* frames it receives.
|
|
7
|
+
*
|
|
8
|
+
* const hub = createChannelHub({ send: (ws, frame) => ws.send(frame), subscribe: (scope, ch, args, ctx) => … })
|
|
9
|
+
* const conn = hub.connect(ws, { scope, ip }) // on open
|
|
10
|
+
* conn.message(text) // on every text frame
|
|
11
|
+
* conn.close() // on close
|
|
12
|
+
* hub.publish(scope, ch, group, message) // a channel's publish
|
|
13
|
+
*
|
|
14
|
+
* `scope` separates the projects one host serves (the runner: a slot's key); a host with one
|
|
15
|
+
* project passes "".
|
|
16
|
+
*
|
|
17
|
+
* Who is subscribed was decided for the session the socket had THEN. So a `hello` that changes the
|
|
18
|
+
* session drops every subscription of the socket — the client sends them again and the hooks run
|
|
19
|
+
* for the new one. A socket's frames are served ONE AT A TIME, in the order they came: a `hello`
|
|
20
|
+
* never overtakes the subscription sent before it, and two subscriptions of a guest do not mint two
|
|
21
|
+
* sessions.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { RequestContext } from "./context"
|
|
25
|
+
import type { InvokeResult } from "./runtime"
|
|
26
|
+
import type { ChannelGroup, ServerFrame } from "../runtime/wire"
|
|
27
|
+
|
|
28
|
+
type Ctx = Omit<RequestContext, "id">
|
|
29
|
+
|
|
30
|
+
/** Where a group's frames go. The default keeps the sockets of a key in a set; a host whose
|
|
31
|
+
* sockets have topics of their own (Bun) hands those over, and one publish is one native call. */
|
|
32
|
+
export type HubFanout<S> = {
|
|
33
|
+
subscribe(socket: S, key: string): void
|
|
34
|
+
/** Also called for every key of a socket that closed. */
|
|
35
|
+
unsubscribe(socket: S, key: string): void
|
|
36
|
+
publish(key: string, frame: string): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ChannelHubOptions<S> = {
|
|
40
|
+
send: (socket: S, frame: string) => void
|
|
41
|
+
/** Run the hooks of channel `ch` (createSubscribeGuard, wherever the bundle lives): `result` = the group. */
|
|
42
|
+
subscribe: (scope: string, ch: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
|
|
43
|
+
fanout?: HubFanout<S>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type HubConnection = {
|
|
47
|
+
message(raw: string): Promise<void>
|
|
48
|
+
close(): void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ChannelHub<S> = {
|
|
52
|
+
connect(socket: S, info: { scope?: string, ip?: string, session?: string | null }): HubConnection
|
|
53
|
+
publish(scope: string, ch: string, group: ChannelGroup | null, message: unknown): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const keyOf = (scope: string, ch: string, group: ChannelGroup | null) => `${scope}|${ch}|${JSON.stringify(group)}`
|
|
57
|
+
|
|
58
|
+
const setFanout = <S>(send: (socket: S, frame: string) => void): HubFanout<S> => {
|
|
59
|
+
const sockets = new Map<string, Set<S>>()
|
|
60
|
+
return {
|
|
61
|
+
subscribe(socket, key) {
|
|
62
|
+
let set = sockets.get(key)
|
|
63
|
+
if (!set) sockets.set(key, set = new Set())
|
|
64
|
+
set.add(socket)
|
|
65
|
+
},
|
|
66
|
+
unsubscribe(socket, key) {
|
|
67
|
+
const set = sockets.get(key)
|
|
68
|
+
if (set?.delete(socket) && set.size === 0) sockets.delete(key)
|
|
69
|
+
},
|
|
70
|
+
publish(key, frame) {
|
|
71
|
+
for (const socket of sockets.get(key) ?? []) send(socket, frame)
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> => {
|
|
77
|
+
const fanout = opts.fanout ?? setFanout(opts.send)
|
|
78
|
+
const frame = (f: ServerFrame) => JSON.stringify(f)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
connect(socket, info) {
|
|
82
|
+
const scope = info.scope ?? ""
|
|
83
|
+
let session = info.session ?? null
|
|
84
|
+
let closed = false
|
|
85
|
+
let queue: Promise<void> = Promise.resolve()
|
|
86
|
+
/** client sub id → key */
|
|
87
|
+
const subs = new Map<number, string>()
|
|
88
|
+
/** key → how many of this socket's subs are on it (the fan-out hears of the first and the last) */
|
|
89
|
+
const counts = new Map<string, number>()
|
|
90
|
+
|
|
91
|
+
const drop = (id: number) => {
|
|
92
|
+
const key = subs.get(id)
|
|
93
|
+
if (key === undefined) return
|
|
94
|
+
subs.delete(id)
|
|
95
|
+
const n = (counts.get(key) ?? 1) - 1
|
|
96
|
+
if (n > 0) { counts.set(key, n); return }
|
|
97
|
+
counts.delete(key)
|
|
98
|
+
fanout.unsubscribe(socket, key)
|
|
99
|
+
}
|
|
100
|
+
const dropAll = () => { for (const id of [...subs.keys()]) drop(id) }
|
|
101
|
+
|
|
102
|
+
const serve = async (msg: any) => {
|
|
103
|
+
switch (msg?.t) {
|
|
104
|
+
case "hello": {
|
|
105
|
+
const next = typeof msg.session === "string" && msg.session ? msg.session : null
|
|
106
|
+
if (next === session) return
|
|
107
|
+
session = next
|
|
108
|
+
dropAll()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
case "sub": {
|
|
112
|
+
const id = Number(msg.id), ch = String(msg.ch ?? "")
|
|
113
|
+
if (!Number.isFinite(id) || !ch) return
|
|
114
|
+
const args = Array.isArray(msg.args) ? msg.args : []
|
|
115
|
+
const ctx: Ctx = { headers: session ? { authorization: `Bearer ${session}` } : {}, ip: info.ip, sessionToken: session ?? undefined }
|
|
116
|
+
const r = await opts.subscribe(scope, ch, args, ctx)
|
|
117
|
+
if (closed) return
|
|
118
|
+
// a token minted by the hooks (the first contact of a guest) → the client stores it
|
|
119
|
+
if (r.session) { session = r.session; opts.send(socket, frame({ t: "session", session: r.session })) }
|
|
120
|
+
if (!r.ok) { opts.send(socket, frame({ t: "err", id, status: r.status, message: r.message })); return }
|
|
121
|
+
const group = (r.result ?? null) as ChannelGroup | null
|
|
122
|
+
const key = keyOf(scope, ch, group)
|
|
123
|
+
drop(id)
|
|
124
|
+
opts.send(socket, frame({ t: "ok", id, group }))
|
|
125
|
+
subs.set(id, key)
|
|
126
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
127
|
+
if (counts.get(key) === 1) fanout.subscribe(socket, key)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
case "unsub":
|
|
131
|
+
drop(Number(msg.id))
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
message(raw) {
|
|
138
|
+
let msg: unknown
|
|
139
|
+
try { msg = JSON.parse(raw) } catch { return queue }
|
|
140
|
+
queue = queue.then(() => closed ? undefined : serve(msg)).catch(() => {})
|
|
141
|
+
return queue
|
|
142
|
+
},
|
|
143
|
+
close() {
|
|
144
|
+
closed = true
|
|
145
|
+
dropAll()
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
publish(scope, ch, group, message) {
|
|
150
|
+
fanout.publish(keyOf(scope, ch, group), frame({ t: "ev", ch, group, data: message === undefined ? null : message }))
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export type RequestContext = {
|
|
10
|
-
/** Endpoint id `<path>#<export>` (or the channel id for
|
|
10
|
+
/** Endpoint id `<path>#<export>` (or the channel id for a subscription's hooks). */
|
|
11
11
|
id: string
|
|
12
12
|
headers: Record<string, string>
|
|
13
13
|
ip?: string
|
|
14
14
|
/** Bearer session token as sent by the client transport (raw; auth resolves it). */
|
|
15
15
|
sessionToken?: string
|
|
16
|
-
/** Set by the auth runtime after resolving the token — read via `auth
|
|
16
|
+
/** Set by the auth runtime after resolving the token — read via `db.auth`, not here. */
|
|
17
17
|
auth?: unknown
|
|
18
18
|
}
|
|
19
19
|
|