@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 +2 -1
- package/dist/index.js +2 -1
- package/dist/realtime/broadcast-hub.js +12 -3
- package/dist/realtime/buffered-amount.js +14 -0
- package/dist/realtime/fixed-window.js +28 -0
- package/dist/realtime/realtime-limits.d.ts +29 -0
- package/dist/realtime/realtime-limits.js +30 -0
- package/dist/realtime/realtime-routes.js +11 -5
- package/dist/realtime/realtime-socket.js +75 -14
- package/dist/webhooks/subscriber-view.js +23 -7
- package/dist/webhooks/webhooks.js +36 -9
- package/package.json +4 -4
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
214
|
+
idle = setTimeout(() => shut(), idleMs);
|
|
164
215
|
};
|
|
165
216
|
stayAwake();
|
|
217
|
+
const fail = (error) => {
|
|
218
|
+
report(error);
|
|
219
|
+
shut();
|
|
220
|
+
};
|
|
166
221
|
return {
|
|
167
|
-
/**
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
44
|
-
const fields = Object.values(model.fields).filter((field) => field.
|
|
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
|
-
|
|
54
|
+
read.push(field.name);
|
|
55
|
+
throw error(field.name);
|
|
51
56
|
}
|
|
52
57
|
});
|
|
53
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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.
|
|
39
|
-
"@protobase/query": "0.
|
|
40
|
-
"@protobase/schema": "0.
|
|
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",
|