@protobase/server 0.34.3 → 0.36.0

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/dist/index.d.ts CHANGED
@@ -62,6 +62,7 @@ import { passwordLength } from "./better-auth/new-account-password.js";
62
62
  import { isOAuthWellKnownPath, oauthWellKnownPaths, webauthnPath } from "./better-auth/origins.js";
63
63
  import { goTruePath } from "./gotrue/keys.js";
64
64
  import { supabasePath } from "./supabase-paths.js";
65
+ import { realtimeLimits } from "./realtime/realtime-limits.js";
65
66
  import { defaultAppName } from "./app-name.js";
66
67
  import { authSchemaMigration, checkAuthSchema } from "./better-auth/auth-schema.js";
67
68
  import { changesMigration, checkChanges, migrationCommand } from "./changes/capture-schema.js";
@@ -111,4 +112,4 @@ import { isSchedule } from "./jobs/schedule.js";
111
112
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
112
113
  import { jobsDrainApp } from "./jobs/drain-route.js";
113
114
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
114
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
115
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, realtimeLimits, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { isOAuthWellKnownPath, oauthWellKnownPaths, webauthnPath } from "./bette
21
21
  import { apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js";
22
22
  import { apiKeyCaller } from "./api-keys/resolve-caller.js";
23
23
  import { jwtAuthenticator, jwtVerifier } from "./auth/jwt.js";
24
+ import { realtimeLimits } from "./realtime/realtime-limits.js";
24
25
  import { objectKey } from "./storage/object-key.js";
25
26
  import { defaultAppName } from "./app-name.js";
26
27
  import { supabasePath } from "./supabase-paths.js";
@@ -82,4 +83,4 @@ import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/p
82
83
  import { defineEmail } from "./mail/define-email.js";
83
84
  import { startJobs } from "./jobs/runtime.js";
84
85
  import { jobsDrainApp } from "./jobs/drain-route.js";
85
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
86
+ export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, realtimeLimits, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
@@ -1,10 +1,14 @@
1
+ import { fixedWindows } from "./fixed-window.js";
2
+ import { realtimeLimits } from "./realtime-limits.js";
1
3
  //#region src/realtime/broadcast-hub.ts
2
4
  /**
3
5
  * The channels of a process by topic, which hear each other's broadcasts in-process: a cloud app runs as one process,
4
- * so channels of other processes are not reached.
6
+ * so channels of other processes are not reached. A topic takes `realtimeLimits.topicBroadcastsPerSecond`, whoever sends
7
+ * them; its window is kept here, so a topic left empty and joined again does not start a new one.
5
8
  */
6
- const broadcastHub = () => {
9
+ const broadcastHub = ({ now = () => Date.now() } = {}) => {
7
10
  const topics = /* @__PURE__ */ new Map();
11
+ const rate = fixedWindows(realtimeLimits.topicBroadcastsPerSecond, 1e3, now);
8
12
  return {
9
13
  /** Puts the member on the topic until the returned function takes it off. */
10
14
  join: (topic, member) => {
@@ -15,9 +19,14 @@ const broadcastHub = () => {
15
19
  if (members.size === 0 && topics.get(topic) === members) topics.delete(topic);
16
20
  };
17
21
  },
18
- /** Sends a broadcast to the topic's members, the sender only when it hears its own. */
22
+ /**
23
+ * Sends a broadcast to the topic's members, the sender only when it hears its own. `false`, sending nothing, when the
24
+ * topic has taken its broadcasts for this second.
25
+ */
19
26
  send: (topic, from, event, payload) => {
27
+ if (!rate(topic)) return false;
20
28
  for (const member of topics.get(topic) ?? []) if (member !== from || member.self) member.receive(event, payload);
29
+ return true;
21
30
  }
22
31
  };
23
32
  };
@@ -0,0 +1,14 @@
1
+ //#region src/realtime/buffered-amount.ts
2
+ const isObject = (value) => typeof value === "object" && value !== null;
3
+ /**
4
+ * The bytes a WebSocket of npm `ws` was sent and has not written out, or `undefined` for any other raw socket. Only one
5
+ * that has the sender and socket that `ws`'s `bufferedAmount` counts is taken: Bun's own `ws` shim has a
6
+ * `bufferedAmount` too, but it stays 0 however much waits.
7
+ */
8
+ const bufferedOf = (raw) => {
9
+ if (!isObject(raw) || !isObject(raw._sender) || !isObject(raw._socket)) return void 0;
10
+ const amount = raw.bufferedAmount;
11
+ return typeof amount === "number" && Number.isFinite(amount) ? amount : void 0;
12
+ };
13
+ //#endregion
14
+ export { bufferedOf };
@@ -0,0 +1,28 @@
1
+ //#region src/realtime/fixed-window.ts
2
+ /**
3
+ * At most `max` takes per key in a fixed window of `windowMs`, which all keys share: it starts at the first take after
4
+ * the last one ended, and then every count starts over, so memory follows the keys of the current window. A refused take
5
+ * does not count. Returns whether one more take for the key fits.
6
+ */
7
+ const fixedWindows = (max, windowMs, now = () => Date.now()) => {
8
+ let start = -Infinity;
9
+ let counts = /* @__PURE__ */ new Map();
10
+ return (key) => {
11
+ const at = now();
12
+ if (at - start >= windowMs) {
13
+ start = at;
14
+ counts = /* @__PURE__ */ new Map();
15
+ }
16
+ const count = counts.get(key) ?? 0;
17
+ if (count >= max) return false;
18
+ counts.set(key, count + 1);
19
+ return true;
20
+ };
21
+ };
22
+ /** `fixedWindows` for one key. */
23
+ const fixedWindow = (max, windowMs, now = () => Date.now()) => {
24
+ const take = fixedWindows(max, windowMs, now);
25
+ return () => take("");
26
+ };
27
+ //#endregion
28
+ export { fixedWindow, fixedWindows };
@@ -0,0 +1,29 @@
1
+ //#region src/realtime/realtime-limits.d.ts
2
+ /**
3
+ * What one socket and one topic of `/api/sb/realtime/v1` may do, near Supabase Realtime's own limits so an app within
4
+ * its Free plan works as before. Each value names its Supabase setting, or says it has none.
5
+ */
6
+ export declare const realtimeLimits: {
7
+ /** A whole frame: the Free plan's 256 KB broadcast payload, with room for the envelope. */
8
+ maxFrameBytes: number;
9
+ /** Broadcast and presence pushes per socket (`TENANT_MAX_EVENTS_PER_SECOND`). */
10
+ eventsPerSecond: number;
11
+ /** `phx_join`s per socket (`TENANT_MAX_JOINS_PER_SECOND`). */
12
+ joinsPerSecond: number;
13
+ /** Broadcasts into one topic, every socket's together; no counterpart. */
14
+ topicBroadcastsPerSecond: number;
15
+ /** Presence pushes per socket and topic in a window (`CLIENT_PRESENCE_MAX_CALLS`). */
16
+ presencePerWindow: number;
17
+ /** `CLIENT_PRESENCE_WINDOW_MS`. */
18
+ presenceWindowMs: number;
19
+ /** The JSON of one tracked meta, in bytes. */
20
+ maxPresenceBytes: number;
21
+ /** Channels joined at once per socket (`TENANT_MAX_CHANNELS_PER_CLIENT`). */
22
+ channelsPerSocket: number;
23
+ /** Output sent to a client that it has not read yet; no counterpart. */
24
+ maxUnsentBytes: number;
25
+ /** Input waiting to be handled, such as behind a token check; no counterpart. */
26
+ maxQueuedMessages: number;
27
+ maxQueuedBytes: number;
28
+ };
29
+ //#endregion
@@ -0,0 +1,30 @@
1
+ //#region src/realtime/realtime-limits.ts
2
+ /**
3
+ * What one socket and one topic of `/api/sb/realtime/v1` may do, near Supabase Realtime's own limits so an app within
4
+ * its Free plan works as before. Each value names its Supabase setting, or says it has none.
5
+ */
6
+ const realtimeLimits = {
7
+ /** A whole frame: the Free plan's 256 KB broadcast payload, with room for the envelope. */
8
+ maxFrameBytes: 262144,
9
+ /** Broadcast and presence pushes per socket (`TENANT_MAX_EVENTS_PER_SECOND`). */
10
+ eventsPerSecond: 100,
11
+ /** `phx_join`s per socket (`TENANT_MAX_JOINS_PER_SECOND`). */
12
+ joinsPerSecond: 100,
13
+ /** Broadcasts into one topic, every socket's together; no counterpart. */
14
+ topicBroadcastsPerSecond: 100,
15
+ /** Presence pushes per socket and topic in a window (`CLIENT_PRESENCE_MAX_CALLS`). */
16
+ presencePerWindow: 5,
17
+ /** `CLIENT_PRESENCE_WINDOW_MS`. */
18
+ presenceWindowMs: 3e4,
19
+ /** The JSON of one tracked meta, in bytes. */
20
+ maxPresenceBytes: 1e4,
21
+ /** Channels joined at once per socket (`TENANT_MAX_CHANNELS_PER_CLIENT`). */
22
+ channelsPerSocket: 100,
23
+ /** Output sent to a client that it has not read yet; no counterpart. */
24
+ maxUnsentBytes: 4194304,
25
+ /** Input waiting to be handled, such as behind a token check; no counterpart. */
26
+ maxQueuedMessages: 300,
27
+ maxQueuedBytes: 4194304
28
+ };
29
+ //#endregion
30
+ export { realtimeLimits };
@@ -1,5 +1,6 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  import { broadcastHub } from "./broadcast-hub.js";
3
+ import { bufferedOf } from "./buffered-amount.js";
3
4
  import { vsnOf } from "./phoenix-message.js";
4
5
  import { presenceHub } from "./presence-hub.js";
5
6
  import { realtimeSocket } from "./realtime-socket.js";
@@ -12,7 +13,8 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
12
13
  /**
13
14
  * `GET /api/sb/realtime/v1/websocket?apikey=…&vsn=…`: Supabase Realtime's socket, for `postgres_changes` of the live
14
15
  * resources, and broadcasts and presence between the sockets of this process (see `realtimeSocket`). A missing or wrong
15
- * key is refused before the upgrade, with 401.
16
+ * key is refused before the upgrade, with 401. A socket whose raw socket is not one of npm `ws`, such as Bun's own `ws`
17
+ * shim's, is closed at once with 1011, since its unsent bytes cannot be counted (`realtimeLimits.maxUnsentBytes`).
16
18
  */
17
19
  const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
18
20
  const app = new Hono();
@@ -29,10 +31,15 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
29
31
  let socket;
30
32
  return {
31
33
  onOpen: (_event, ws) => {
34
+ if (bufferedOf(ws.raw) === void 0) {
35
+ report(/* @__PURE__ */ new Error("Not implemented: realtime needs WebSockets of npm ws, which count unsent bytes; on Bun, load npm ws by its path rather than Bun's ws shim, or run on Node"));
36
+ return ws.close(1011);
37
+ }
32
38
  socket = realtimeSocket({
33
39
  vsn: vsnOf(c.req.query("vsn")),
34
40
  send: (frame) => ws.send(frame),
35
- close: () => ws.close(),
41
+ buffered: () => bufferedOf(ws.raw),
42
+ close: (code) => ws.close(code),
36
43
  resolve: (token) => caller(keyRequest(apikey, token)),
37
44
  live,
38
45
  hub,
@@ -42,9 +49,8 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
42
49
  report
43
50
  });
44
51
  },
45
- onMessage: (event) => {
46
- if (typeof event.data === "string" || event.data instanceof ArrayBuffer) socket?.message(event.data);
47
- },
52
+ onMessage: (event) => socket?.message(event.data),
53
+ onError: () => socket?.closed(),
48
54
  onClose: () => socket?.closed()
49
55
  };
50
56
  }));
@@ -1,11 +1,23 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  import { parseBindings } from "./postgres-changes.js";
3
+ import { fixedWindow, fixedWindows } from "./fixed-window.js";
4
+ import { realtimeLimits } from "./realtime-limits.js";
3
5
  import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
4
6
  import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
5
7
  import { randomUUID } from "node:crypto";
8
+ import { Buffer } from "node:buffer";
6
9
  //#region src/realtime/realtime-socket.ts
7
10
  const tokenOf = (payload) => typeof payload.access_token === "string" && payload.access_token !== "" ? payload.access_token : void 0;
8
11
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
+ const messageTooBig = 1009;
13
+ const policyViolation = 1008;
14
+ const frameBytes = (frame) => {
15
+ if (typeof frame === "string") return Buffer.byteLength(frame);
16
+ if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) return frame.byteLength;
17
+ if (frame instanceof Blob) return frame.size;
18
+ return NaN;
19
+ };
20
+ const tooFast = "Too many messages per second";
9
21
  const refusal = (error) => {
10
22
  if (error instanceof HttpProblem && error.status === 401) return error.detail;
11
23
  throw error;
@@ -15,12 +27,42 @@ const refusal = (error) => {
15
27
  * `postgres_changes` bindings and an access token, token refreshes on an open channel, broadcasts with their `self`
16
28
  * and `ack` options, presence tracking, and leaves. A channel's token ends it when it expires, unless a newer one came
17
29
  * first. Messages are handled one at a time, in order. Private channels are refused for now.
30
+ *
31
+ * It holds a client to `realtimeLimits`: a push over a rate, a channel over the cap or a presence meta too big gets an
32
+ * error reply where Supabase would close the channel, and is not relayed. Rates count per socket, presence per socket
33
+ * and topic, so leaving and joining again does not reset them; the broadcast hub counts each topic's. The socket is
34
+ * closed, 1009, for a frame over the size cap, and 1008 when its client leaves too much unread or sends too much while
35
+ * earlier messages wait. A close, the client's or the server's, leaves every channel, a join still checking its token
36
+ * included, and drops whatever is still queued.
18
37
  */
19
- const realtimeSocket = ({ vsn, send, close, resolve, live, hub, broadcasts, presences, nextId, report, idleMs = 6e4, now = Date.now }) => {
38
+ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadcasts, presences, nextId, report, idleMs = 6e4, now = Date.now }) => {
20
39
  const channels = /* @__PURE__ */ new Map();
21
40
  let queue = Promise.resolve();
41
+ let queuedMessages = 0;
42
+ let queuedBytes = 0;
22
43
  let idle;
23
- const push = (message) => send(encodeMessage(vsn, message));
44
+ let open = true;
45
+ const events = fixedWindow(realtimeLimits.eventsPerSecond, 1e3, now);
46
+ const joins = fixedWindow(realtimeLimits.joinsPerSecond, 1e3, now);
47
+ const presenceRate = fixedWindows(realtimeLimits.presencePerWindow, realtimeLimits.presenceWindowMs, now);
48
+ const leaveAll = () => {
49
+ open = false;
50
+ clearTimeout(idle);
51
+ for (const topic of [...channels.keys()]) leave(topic);
52
+ };
53
+ const shut = (code) => {
54
+ if (!open) return;
55
+ open = false;
56
+ clearTimeout(idle);
57
+ close(code);
58
+ Promise.resolve().then(leaveAll).catch(report);
59
+ };
60
+ const deliver = (frame) => {
61
+ if (!open) return;
62
+ if (!(buffered() <= realtimeLimits.maxUnsentBytes)) return shut(policyViolation);
63
+ send(frame);
64
+ };
65
+ const push = (message) => deliver(encodeMessage(vsn, message));
24
66
  const toChannel = (topic, channel, event, payload) => push({
25
67
  joinRef: channel.joinRef,
26
68
  ref: null,
@@ -29,7 +71,7 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, broadcasts, pres
29
71
  payload
30
72
  });
31
73
  const hear = (topic, channel, event, payload) => {
32
- if (vsn === "2.0.0") return send(encodeBroadcast(topic, event, payload));
74
+ if (vsn === "2.0.0") return deliver(encodeBroadcast(topic, event, payload));
33
75
  if (payload instanceof ArrayBuffer) return;
34
76
  toChannel(topic, channel, "broadcast", {
35
77
  type: "broadcast",
@@ -62,12 +104,16 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, broadcasts, pres
62
104
  channel.expiry = setTimeout(() => end(topic, "Token has expired"), Math.max(0, exp * 1e3 - now()));
63
105
  };
64
106
  const join = async (message) => {
107
+ if (!joins()) return push(reply(message, "error", { reason: "ClientJoinRateLimitReached: Too many joins per second" }));
108
+ if (!fitsFrame(message.topic)) return push(reply(message, "error", { reason: "A topic is at most 255 bytes" }));
109
+ if (!channels.has(message.topic) && channels.size >= realtimeLimits.channelsPerSocket) return push(reply(message, "error", { reason: "ChannelRateLimitReached: Too many channels" }));
65
110
  leave(message.topic);
66
111
  const config = isRecord(message.payload.config) ? message.payload.config : {};
67
112
  if (config.private === true) return push(reply(message, "error", { reason: "Not implemented: private channels" }));
68
113
  const broadcast = isRecord(config.broadcast) ? config.broadcast : {};
69
114
  const presence = isRecord(config.presence) ? config.presence : {};
70
115
  const caller = await resolve(tokenOf(message.payload)).catch(refusal);
116
+ if (!open) return;
71
117
  if (typeof caller === "string") return push(reply(message, "error", { reason: caller }));
72
118
  const parsed = parseBindings(config.postgres_changes, live, nextId);
73
119
  if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
@@ -122,19 +168,24 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, broadcasts, pres
122
168
  };
123
169
  const sendBroadcast = (message, channel) => {
124
170
  const { event, payload } = message.payload;
171
+ if (!events()) return push(reply(message, "error", { reason: tooFast }));
125
172
  if (typeof event !== "string") return push(reply(message, "error", { reason: "A broadcast needs an event" }));
126
173
  if (!fitsFrame(message.topic) || !fitsFrame(event)) return push(reply(message, "error", { reason: "A broadcast topic and event are at most 255 bytes each" }));
127
- broadcasts.send(message.topic, channel.member, event, payload);
174
+ if (!broadcasts.send(message.topic, channel.member, event, payload)) return push(reply(message, "error", { reason: `${tooFast} on this topic` }));
128
175
  if (channel.ack) push(reply(message, "ok", {}));
129
176
  };
130
177
  const updatePresence = (message, channel) => {
131
178
  const { event, payload } = message.payload;
179
+ if (!events()) return push(reply(message, "error", { reason: tooFast }));
180
+ if (!presenceRate(message.topic)) return push(reply(message, "error", { reason: "Client presence rate limit exceeded" }));
181
+ if (event === "track" && isRecord(payload) && Buffer.byteLength(JSON.stringify(payload)) > realtimeLimits.maxPresenceBytes) return push(reply(message, "error", { reason: "Track message size exceeded" }));
132
182
  if (event === "track" && isRecord(payload)) channel.presence.track(payload);
133
183
  else if (event === "untrack") channel.presence.untrack();
134
184
  else return push(reply(message, "error", { reason: "A presence message tracks an object or untracks" }));
135
185
  push(reply(message, "ok", {}));
136
186
  };
137
187
  const handle = async (message) => {
188
+ if (!open) return;
138
189
  if (message.topic === "phoenix") {
139
190
  if (message.event === "heartbeat") push(reply(message, "ok", {}));
140
191
  return;
@@ -160,25 +211,35 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, broadcasts, pres
160
211
  };
161
212
  const stayAwake = () => {
162
213
  clearTimeout(idle);
163
- idle = setTimeout(close, idleMs);
214
+ idle = setTimeout(() => shut(), idleMs);
164
215
  };
165
216
  stayAwake();
217
+ const fail = (error) => {
218
+ report(error);
219
+ shut();
220
+ };
166
221
  return {
167
- /** Handles a frame after those before it: text, or binary for a broadcast. */
222
+ /**
223
+ * Handles a frame after those before it: text, or an `ArrayBuffer` for a broadcast. Another kind is ignored, or
224
+ * closes the socket as too big when its size is unknown.
225
+ */
168
226
  message: (frame) => {
227
+ if (!open) return;
228
+ const size = frameBytes(frame);
229
+ if (!(size <= realtimeLimits.maxFrameBytes)) return shut(messageTooBig);
169
230
  stayAwake();
170
- const message = typeof frame === "string" ? decodeMessage(vsn, frame) : decodeBroadcastPush(frame);
231
+ const message = typeof frame === "string" ? decodeMessage(vsn, frame) : frame instanceof ArrayBuffer ? decodeBroadcastPush(frame) : void 0;
171
232
  if (!message) return;
172
- queue = queue.then(() => handle(message)).catch((error) => {
173
- report(error);
174
- close();
233
+ queuedMessages += 1;
234
+ queuedBytes += size;
235
+ if (queuedMessages > realtimeLimits.maxQueuedMessages || queuedBytes > realtimeLimits.maxQueuedBytes) return shut(policyViolation);
236
+ queue = queue.then(() => handle(message)).catch(fail).finally(() => {
237
+ queuedMessages -= 1;
238
+ queuedBytes -= size;
175
239
  });
176
240
  },
177
241
  /** Ends every channel of a socket that closed. */
178
- closed: () => {
179
- clearTimeout(idle);
180
- for (const topic of [...channels.keys()]) leave(topic);
181
- }
242
+ closed: leaveAll
182
243
  };
183
244
  };
184
245
  //#endregion
@@ -40,20 +40,30 @@ const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
40
40
  transaction: change.transaction
41
41
  }
42
42
  });
43
- const withUnknownEncrypted = (model, record) => {
44
- const fields = Object.values(model.fields).filter((field) => field.encrypted);
45
- if (fields.length === 0) return record;
43
+ const withUnknownFields = (model, record) => {
44
+ const fields = Object.values(model.fields).filter((field) => field.sensitive || !Object.hasOwn(record, field.name));
46
45
  const unknown = { ...record };
46
+ const read = [];
47
+ const error = (name) => {
48
+ const field = model.fields[name];
49
+ return /* @__PURE__ */ new Error(`a subscription's access check cannot tell whether the ${field.sensitive ? "sensitive field" : "field"} "${name}" is set${field.sensitive ? "" : ", which the captured change does not hold"}`);
50
+ };
47
51
  for (const field of fields) Object.defineProperty(unknown, field.name, {
48
52
  enumerable: true,
49
53
  get: () => {
50
- throw new Error(`a subscription's access check cannot tell whether the encrypted field "${field.name}" is set`);
54
+ read.push(field.name);
55
+ throw error(field.name);
51
56
  }
52
57
  });
53
- return unknown;
58
+ const check = () => {
59
+ if (read.length > 0) throw error(read[0]);
60
+ };
61
+ return {
62
+ record: unknown,
63
+ check
64
+ };
54
65
  };
55
- const mayRead = async (access, captured) => {
56
- const record = withUnknownEncrypted(access.full, captured);
66
+ const decide = async (access, record) => {
57
67
  const { resolved, session, entry } = access;
58
68
  if (!resolved.operations.read) return false;
59
69
  if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
@@ -64,6 +74,12 @@ const mayRead = async (access, captured) => {
64
74
  };
65
75
  return checkRecord(entry.source, context, "read", record, void 0, access.deps.accessOptions);
66
76
  };
77
+ const mayRead = async (access, captured) => {
78
+ const { record, check } = withUnknownFields(access.full, captured);
79
+ const allowed = await decide(access, record);
80
+ check();
81
+ return allowed;
82
+ };
67
83
  /**
68
84
  * Builds each subscriber's event, under the access rules of the session it delivers as, resolved once per subscription
69
85
  * and resource: `undefined` when it may not read the record, after the change or before a delete.
@@ -47,7 +47,9 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
47
47
  * before that commits leaves the rows, and the job's retry delivers them again, under the same `webhook-id`.
48
48
  * - `webhooks.dispatch-change`, one change whose app code threw in dispatch: an event's `match` or filter, a
49
49
  * subscription's `when`, or an access rule of its roles. Kept as failed, so dispatch moves on to later changes; a
50
- * retry after the fix queues the change's deliveries. Any other error fails the whole dispatch, which is retried.
50
+ * retry after the fix queues the change's deliveries. An access rule's failure keeps the change for that subscription
51
+ * only: the others still receive it, and the retry delivers to that subscription alone. Any other error fails the
52
+ * whole dispatch, which is retried.
51
53
  * - `webhooks.deliver`, one try of an unordered delivery; a failure queues the next try on the Standard Webhooks
52
54
  * schedule, or Retry-After, and the last one is kept as failed.
53
55
  * - `webhooks.deliver-ordered`, the ordered deliveries of one dispatch for one subscription and record, retried with
@@ -148,17 +150,32 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
148
150
  const changeDeliveries = async (db) => {
149
151
  const route = subscriptionRouter(await current(db), models);
150
152
  const eventOf = subscriberEvents(deps);
151
- return async (change) => {
153
+ return async (change, only) => {
154
+ const routes = route(change, matchers.get(change.resource)(change)).filter(({ subscription }) => only === void 0 || subscription.name === only);
152
155
  const deliveries = [];
153
- for (const { subscription, event } of route(change, matchers.get(change.resource)(change))) {
154
- const shaped = await eventOf(subscription, change, event);
156
+ const failures = [];
157
+ const failed = /* @__PURE__ */ new Set();
158
+ for (const { subscription, event } of routes) {
159
+ if (failed.has(subscription.name)) continue;
160
+ const shaped = await eventOf(subscription, change, event).catch((error) => {
161
+ if (!(error instanceof EventCodeError)) throw error;
162
+ failed.add(subscription.name);
163
+ failures.push({
164
+ change,
165
+ subscription: subscription.name,
166
+ error
167
+ });
168
+ });
155
169
  if (shaped) deliveries.push({
156
170
  subscription,
157
171
  event: shaped,
158
172
  change
159
173
  });
160
174
  }
161
- return deliveries;
175
+ return {
176
+ deliveries: deliveries.filter(({ subscription }) => !failed.has(subscription.name)),
177
+ failures
178
+ };
162
179
  };
163
180
  };
164
181
  const queueOrdered = async (db, deliveries) => {
@@ -178,8 +195,9 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
178
195
  name: "webhooks.dispatch-change",
179
196
  maxAttempts: 1,
180
197
  timeout: "5 minutes",
181
- run: async ({ change }, { db }) => {
182
- const deliveries = await (await changeDeliveries(db))(change);
198
+ run: async ({ change, subscription: only }, { db }) => {
199
+ const { deliveries, failures } = await (await changeDeliveries(db))(change, only);
200
+ if (only !== void 0 && failures.length > 0) throw failures[0].error;
183
201
  await db.transaction().execute(async (trx) => {
184
202
  for (const { subscription, event } of deliveries.filter(({ subscription }) => !subscription.options.ordered)) {
185
203
  const payload = {
@@ -190,6 +208,10 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
190
208
  await deliver.enqueue(trx, payload, { key: deliveryKey(payload) });
191
209
  }
192
210
  await queueOrdered(trx, deliveries);
211
+ for (const { subscription } of failures) await dispatchChange.enqueue(trx, {
212
+ change,
213
+ subscription
214
+ });
193
215
  });
194
216
  }
195
217
  });
@@ -204,7 +226,9 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
204
226
  const deliveries = [];
205
227
  const failures = [];
206
228
  for (const change of changes) try {
207
- deliveries.push(...await deliveriesOf(change));
229
+ const routed = await deliveriesOf(change);
230
+ deliveries.push(...routed.deliveries);
231
+ failures.push(...routed.failures);
208
232
  } catch (error) {
209
233
  if (!(error instanceof EventCodeError)) throw error;
210
234
  failures.push({
@@ -232,7 +256,10 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
232
256
  });
233
257
  }
234
258
  await queueOrdered(trx, deliveries);
235
- for (const { change } of failures) await dispatchChange.enqueue(trx, { change });
259
+ for (const { change, subscription } of failures) await dispatchChange.enqueue(trx, {
260
+ change,
261
+ ...subscription !== void 0 && { subscription }
262
+ });
236
263
  await writeHistory(trx, schema, changes.filter((change) => deps.registry.find(change.resource)?.model.history));
237
264
  await writeNotices(trx, schema, changes.filter((change) => deps.registry.find(change.resource)?.model.live));
238
265
  await deleteOutbox(trx, schema, rows.map((row) => row.id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.34.3",
3
+ "version": "0.36.0",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -35,9 +35,9 @@
35
35
  "@better-auth/oauth-provider": "^1.7.12",
36
36
  "@better-auth/passkey": "^1.7.12",
37
37
  "@better-auth/sso": "^1.7.12",
38
- "@protobase/layout": "0.34.3",
39
- "@protobase/query": "0.34.3",
40
- "@protobase/schema": "0.34.3",
38
+ "@protobase/layout": "0.36.0",
39
+ "@protobase/query": "0.36.0",
40
+ "@protobase/schema": "0.36.0",
41
41
  "@react-email/render": "^2.1.0",
42
42
  "@scalar/hono-api-reference": "^0.12.10",
43
43
  "@sentry/core": "^11.7.0",