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.
Files changed (72) hide show
  1. package/README.md +104 -76
  2. package/dist/global.d.ts +2 -5
  3. package/dist/host.d.ts +3 -0
  4. package/dist/types/inject.d.ts +3 -3
  5. package/dist/types/runtime/device.d.ts +7 -0
  6. package/dist/types/runtime/rpc.d.ts +11 -17
  7. package/dist/types/runtime/wire.d.ts +53 -0
  8. package/dist/types/server/auth/api.d.ts +42 -0
  9. package/dist/types/server/auth/appConfig.d.ts +1 -5
  10. package/dist/types/server/auth/models.d.ts +119 -70
  11. package/dist/types/server/auth/types.d.ts +19 -43
  12. package/dist/types/server/channel.d.ts +57 -19
  13. package/dist/types/server/context.d.ts +2 -2
  14. package/dist/types/server/db/defineDb.d.ts +10 -0
  15. package/dist/types/server/db/index.d.ts +1 -1
  16. package/dist/types/server/db/types.d.ts +76 -6
  17. package/dist/types/server/inject.d.ts +0 -1
  18. package/dist/types/ui/UINode.d.ts +19 -5
  19. package/dist/types/ui/UIScreen.d.ts +1 -0
  20. package/dist/types/ui/UITabs.d.ts +8 -6
  21. package/dist/types/ui/theme.d.ts +48 -13
  22. package/dist/types/version.d.ts +1 -1
  23. package/dist/types.json +1 -1
  24. package/package.json +3 -2
  25. package/prompts/README.md +1 -1
  26. package/prompts/design.md +19 -19
  27. package/prompts/dist/2d-game.md +45 -31
  28. package/prompts/dist/3d-app.md +45 -31
  29. package/prompts/dist/ar-app.md +45 -31
  30. package/prompts/dist/design.md +25 -24
  31. package/prompts/dist/ui-app.md +45 -31
  32. package/prompts/ui-design.md +6 -5
  33. package/prompts/ui.md +25 -22
  34. package/src/bridges/device.d.ts +9 -0
  35. package/src/bridges/tree.d.ts +5 -0
  36. package/src/chisel.ts +1 -1
  37. package/src/compile/bundler.ts +6 -0
  38. package/src/compile/compileProject.ts +3 -1
  39. package/src/compile/index.ts +3 -1
  40. package/src/compile/serverSplit.ts +58 -11
  41. package/src/compile/serverTypes.ts +189 -8
  42. package/src/host.d.ts +3 -0
  43. package/src/inject.ts +6 -6
  44. package/src/runtime/device.ts +12 -0
  45. package/src/runtime/rpc.ts +101 -40
  46. package/src/runtime/wire.ts +35 -0
  47. package/src/server/auth/api.ts +94 -0
  48. package/src/server/auth/appConfig.ts +2 -3
  49. package/src/server/auth/host.ts +244 -174
  50. package/src/server/auth/models.ts +45 -62
  51. package/src/server/auth/types.ts +19 -34
  52. package/src/server/channel.ts +97 -29
  53. package/src/server/channelHub.ts +153 -0
  54. package/src/server/context.ts +2 -2
  55. package/src/server/db/defineDb.ts +96 -36
  56. package/src/server/db/index.ts +1 -1
  57. package/src/server/db/types.ts +76 -8
  58. package/src/server/host.ts +25 -10
  59. package/src/server/inject.ts +2 -2
  60. package/src/server/runtime.ts +34 -12
  61. package/src/ui/UINode.ts +22 -5
  62. package/src/ui/UIScreen.ts +5 -0
  63. package/src/ui/UITabs.ts +19 -17
  64. package/src/ui/styleColor.ts +10 -1
  65. package/src/ui/theme.ts +96 -41
  66. package/src/version.ts +1 -1
  67. package/tests/helpers/fakeTree.ts +1 -0
  68. package/dist/types/plugins/oauth.d.ts +0 -25
  69. package/dist/types/server/auth/global.d.ts +0 -56
  70. package/src/plugins/oauth.ts +0 -61
  71. package/src/server/auth/global.ts +0 -80
  72. package/tests/helpers/memoryMarci.ts +0 -124
@@ -1,83 +1,66 @@
1
1
  /**
2
- * `auth.model.user({...})` / `auth.model.session()` — the ONLY place auth touches the schema
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
- * Provider identity columns (`googleId`, `appleId`) follow `app.json` `auth.providers`, which the
9
- * compiler hands the server bundle through ./appConfig.ts (a module it replaces at compile time).
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, type Field } from "../db/fields"
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, AuthProvider }
17
+ export type { AppConfig } from "./appConfig"
17
18
 
18
- const seam = globalThis as unknown as { __lecodesApp?: AppConfig }
19
- /** The project's `app.json` subset: the compiled-in module, else the test/tooling seam, else empty. */
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
- /** Marker on the two auth models (`model()` objects are otherwise plain). */
28
- export type AuthModelKind = "user" | "session"
29
- export type AuthModel<F extends Fields> = Model<F> & { readonly __auth: AuthModelKind }
30
-
31
- // ───────────────────────────── field sets ─────────────────────────────
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 UserFields = ReturnType<typeof userFields>
31
+ export type SessionPublicFields = ReturnType<typeof sessionPublicFields>
43
32
 
44
- /** `<provider>Id` identity columns for the OAuth providers enabled in app.json. */
45
- const providerFields = (): Record<string, Field<any, any>> => {
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
- createdAt: t.date().default("now"),
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
- // Built inline (not via defineDb's `model()`) to keep this module import-free of defineDb, which imports us.
71
- const authModel = <F extends Fields>(kind: AuthModelKind, fields: F): AuthModel<F> =>
72
- ({ fields, __model: true, __auth: kind }) as AuthModel<F>
73
-
74
- /** `auth.model.user({...extra})` — the built-in user (+ provider ids from app.json) plus the developer's fields. */
75
- export const authUserModel = <E extends Fields = {}>(extra?: E): AuthModel<UserFields & E> =>
76
- authModel("user", { ...userFields(), ...providerFields(), ...(extra ?? {}) } as UserFields & E)
77
-
78
- /** `auth.model.session({...extra})` — declare it only to relate to sessions (`t.one("Session")`) or to add fields. */
79
- export const authSessionModel = <E extends Fields = {}>(extra?: E): AuthModel<SessionFields & E> =>
80
- authModel("session", { ...sessionFields(), ...(extra ?? {}) } as unknown as SessionFields & E)
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
- export const isAuthModel = (m: unknown, kind?: AuthModelKind): m is AuthModel<any> =>
83
- typeof m === "object" && m !== null && "__auth" in m && (kind === undefined || (m as AuthModel<any>).__auth === kind)
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
+ })
@@ -1,50 +1,35 @@
1
1
  /**
2
- * Shared shapes of the auth layer (docs/backend-plan.md §3.4). Two sides meet through them:
3
- * - the BUNDLE side (`auth` global, ./global.ts) — what `*.server.ts` code calls;
4
- * - the HOST side (./host.ts) — sessions, email codes and OAuth binding, run by the runner with the
5
- * project's db handle and the platform's secrets. Installed on `globalThis.__lecodesAuth`.
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
- /** The built-in user row — what `auth.user` is typed with (custom fields exist at runtime; select them via `db.user`). */
12
- export type AuthUser = {
13
- id: number
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
- /** What an OAuth provider hands the runner after a successful web sign-in. */
31
- export type OAuthProfile = { sub: string, email?: string | null, emailVerified?: boolean, name?: string | null, avatar?: string | null }
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
- email: {
39
- sendCode(ctx: RequestContext, email: string): Promise<void>
40
- verify(ctx: RequestContext, email: string, code: string): Promise<AuthUser>
41
- }
42
- oauth: {
43
- url(ctx: RequestContext, provider: Exclude<AuthProvider, "email">): Promise<string>
44
- signIn(ctx: RequestContext, provider: Exclude<AuthProvider, "email">, credential: OAuthCredential): Promise<AuthUser>
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
- /** `db.session` filtered to the current user's sessions (or just this device's, when signed out). */
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
  }
@@ -1,56 +1,124 @@
1
1
  /**
2
- * Server side of channels (docs/backend-plan.md §3.3): typed pub/sub, server → client only.
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
- * type ChatEvents = { message: { from: string; text: string }; typing: { from: string } }
5
- * export const chat = channel<ChatEvents>({ onJoin: room => { auth.requireUser() } })
6
- * chat.publish(room, "message", { from, text })
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 runner installs (same reason as ./context.ts: the bundle
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
- export type ChannelEvents = Record<string, unknown>
27
+ import type { ChannelGroup } from "../runtime/wire"
28
+
29
+ export type { ChannelGroup }
15
30
 
16
- export type ChannelOptions = {
17
- /** Called when a client subscribes to `topic`; throw (e.g. `ApiError(403)`) to refuse. */
18
- onJoin?: (topic: string) => void | Promise<void>
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
- export interface Channel<E extends ChannelEvents> {
22
- /** @internal set by the runtime: `<path>#<export>` */
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
- readonly options: ChannelOptions
27
- /** Deliver `event` with `payload` to every subscriber of `topic` (at-most-once). */
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, topic: string, event: string, payload: unknown) => void
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 channel = <E extends ChannelEvents>(options: ChannelOptions = {}): Channel<E> => {
41
- const ch: Channel<E> = {
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
- options,
45
- publish(topic, event, payload) {
46
- if (!ch.__id) throw new Error("channel.publish: the channel is not registered — export it from a *.server.ts file")
47
- const publish = seam.__lecodesPublish
48
- if (!publish) throw new Error("channel.publish: no host publisher installed")
49
- publish(ch.__id, topic, event, payload)
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 Channel<any> =>
56
- typeof v === "object" && v !== null && (v as Channel<any>).__channel === true
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
+ }
@@ -7,13 +7,13 @@
7
7
  */
8
8
 
9
9
  export type RequestContext = {
10
- /** Endpoint id `<path>#<export>` (or the channel id for `onJoin`). */
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.*`, not here. */
16
+ /** Set by the auth runtime after resolving the token — read via `db.auth`, not here. */
17
17
  auth?: unknown
18
18
  }
19
19