@protobase/server 0.26.1 → 0.26.3

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.
@@ -270,27 +270,25 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
270
270
  app.route("/", batchRoute(deps, authenticate, meta));
271
271
  app.route("/", fileRoutes(deps, authenticate));
272
272
  if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
273
- if (appModels.some((model) => model.live)) {
274
- const report = options.onUnhandledError ?? ((error) => console.error(error));
275
- const feed = tableFeed({
276
- db,
277
- schema: changes,
273
+ const report = options.onUnhandledError ?? ((error) => console.error(error));
274
+ const feed = tableFeed({
275
+ db,
276
+ schema: changes,
277
+ report
278
+ });
279
+ if (appModels.some((model) => model.live)) app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
280
+ if (upgradeWebSocket && (apiKeys.publishable || apiKeys.secret)) {
281
+ const verify = verifyToken ?? (auth ? jwtVerifier(betterAuthTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
282
+ app.route("/realtime/v1", realtimeApp({
283
+ deps,
284
+ feed,
285
+ upgradeWebSocket,
286
+ caller: apiKeyCaller({
287
+ keys: apiKeys,
288
+ verify
289
+ }),
278
290
  report
279
- });
280
- app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
281
- if (upgradeWebSocket && (apiKeys.publishable || apiKeys.secret)) {
282
- const verify = verifyToken ?? (auth ? jwtVerifier(betterAuthTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
283
- app.route("/realtime/v1", realtimeApp({
284
- deps,
285
- feed,
286
- upgradeWebSocket,
287
- caller: apiKeyCaller({
288
- keys: apiKeys,
289
- verify
290
- }),
291
- report
292
- }));
293
- }
291
+ }));
294
292
  }
295
293
  app.route(basePath, api);
296
294
  app.route(`${deps.systemPath}/meta`, metaApp);
package/dist/index.d.ts CHANGED
@@ -92,7 +92,8 @@ import { FileRef, formatUri, variantPath } from "./files/uri.js";
92
92
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
93
93
  import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
94
94
  import { FatalJobError } from "./jobs/run-job.js";
95
+ import { isSchedule } from "./jobs/schedule.js";
95
96
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
96
97
  import { jobsDrainApp } from "./jobs/drain-route.js";
97
98
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
98
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, 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 EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, 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 FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, 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 SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, 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, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, 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, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
99
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, 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 EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, 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 FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, 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 SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, 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, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, 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, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { HttpProblem, MissingCredentials, badRequest, forbidden, notFound, unaut
2
2
  import { formatUri, variantPath } from "./files/uri.js";
3
3
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
4
4
  import { isCaptured } from "./changes/capture-sql.js";
5
+ import { isSchedule } from "./jobs/schedule.js";
5
6
  import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
6
7
  import { FatalJobError } from "./jobs/run-job.js";
7
8
  import { ModelError, streamChatCompletion } from "./assistant/chat-completions.js";
@@ -72,4 +73,4 @@ import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/p
72
73
  import { defineEmail } from "./mail/define-email.js";
73
74
  import { startJobs } from "./jobs/runtime.js";
74
75
  import { jobsDrainApp } from "./jobs/drain-route.js";
75
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, 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, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
76
+ export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, 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, storeFile, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
@@ -0,0 +1,4 @@
1
+ //#region src/jobs/schedule.d.ts
2
+ /** Whether a recurring job may take `schedule` in `timezone`: whether cron-parser reads it there. */
3
+ export declare const isSchedule: (schedule: string, timezone: string) => boolean;
4
+ //#endregion
@@ -11,6 +11,16 @@ const nextRun = (schedule, timezone, after) => CronExpressionParser.parse(schedu
11
11
  currentDate: after,
12
12
  tz: timezone
13
13
  }).next().toDate();
14
+ /** Whether a recurring job may take `schedule` in `timezone`: whether cron-parser reads it there. */
15
+ const isSchedule = (schedule, timezone) => {
16
+ try {
17
+ nextRun(schedule, timezone, /* @__PURE__ */ new Date());
18
+ return true;
19
+ } catch (error) {
20
+ if (!(error instanceof Error)) throw error;
21
+ return false;
22
+ }
23
+ };
14
24
  /** Whether `at` is a run of `schedule`, so a run left from an earlier schedule can be told apart. */
15
25
  const isRun = (schedule, timezone, at) => nextRun(schedule, timezone, /* @__PURE__ */ new Date(at.getTime() - 1)).getTime() === at.getTime();
16
26
  /**
@@ -22,4 +32,4 @@ const runId = (name, at) => {
22
32
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${(parseInt(hex[16], 16) & 3 | 8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
23
33
  };
24
34
  //#endregion
25
- export { isRun, nextRun, runId, scheduleKey };
35
+ export { isRun, isSchedule, nextRun, runId, scheduleKey };
@@ -0,0 +1,71 @@
1
+ //#region src/realtime/broadcast-frame.ts
2
+ const userBroadcastPush = 3;
3
+ const userBroadcast = 4;
4
+ const binaryEncoding = 0;
5
+ const jsonEncoding = 1;
6
+ const decoder = new TextDecoder();
7
+ const encoder = new TextEncoder();
8
+ /**
9
+ * A binary frame as the `broadcast` push it carries: a header of kind, the byte lengths of join ref, ref, topic, user
10
+ * event and metadata, and the payload's encoding, then those fields and the payload. The metadata is dropped, as only
11
+ * keys the server knows go in it. `undefined` for a frame of another kind or one cut short.
12
+ */
13
+ const decodeBroadcastPush = (frame) => {
14
+ const bytes = new Uint8Array(frame);
15
+ if (bytes.length < 7 || bytes[0] !== userBroadcastPush) return void 0;
16
+ const [joinRefSize, refSize, topicSize, eventSize, metadataSize, encoding] = bytes.subarray(1, 7);
17
+ let offset = 7;
18
+ const field = (size) => decoder.decode(bytes.subarray(offset, offset += size));
19
+ const joinRef = field(joinRefSize);
20
+ const ref = field(refSize);
21
+ const topic = field(topicSize);
22
+ const event = field(eventSize);
23
+ offset += metadataSize;
24
+ if (offset > bytes.length) return void 0;
25
+ const body = frame.slice(offset);
26
+ let payload = body;
27
+ if (encoding === jsonEncoding) try {
28
+ payload = JSON.parse(decoder.decode(body));
29
+ } catch (error) {
30
+ if (error instanceof SyntaxError) return void 0;
31
+ throw error;
32
+ }
33
+ return {
34
+ joinRef: joinRef || null,
35
+ ref: ref || null,
36
+ topic,
37
+ event: "broadcast",
38
+ payload: {
39
+ type: "broadcast",
40
+ event,
41
+ payload
42
+ }
43
+ };
44
+ };
45
+ /** Whether a topic or event fits a binary frame's one-byte length. */
46
+ const fitsFrame = (text) => encoder.encode(text).length <= 255;
47
+ /**
48
+ * A broadcast as the binary frame realtime-js reads: kind, topic, user event and metadata lengths, encoding, fields,
49
+ * payload. A payload is JSON, or the bytes of a binary broadcast.
50
+ */
51
+ const encodeBroadcast = (topic, event, payload) => {
52
+ const topicBytes = encoder.encode(topic);
53
+ const eventBytes = encoder.encode(event);
54
+ const binary = payload instanceof ArrayBuffer;
55
+ const body = binary ? new Uint8Array(payload) : encoder.encode(JSON.stringify(payload ?? {}));
56
+ if (topicBytes.length > 255 || eventBytes.length > 255) throw new Error("A broadcast topic and event are at most 255 bytes each");
57
+ const frame = new Uint8Array(5 + topicBytes.length + eventBytes.length + body.length);
58
+ frame.set([
59
+ userBroadcast,
60
+ topicBytes.length,
61
+ eventBytes.length,
62
+ 0,
63
+ binary ? binaryEncoding : jsonEncoding
64
+ ]);
65
+ frame.set(topicBytes, 5);
66
+ frame.set(eventBytes, 5 + topicBytes.length);
67
+ frame.set(body, 5 + topicBytes.length + eventBytes.length);
68
+ return frame.buffer;
69
+ };
70
+ //#endregion
71
+ export { decodeBroadcastPush, encodeBroadcast, fitsFrame };
@@ -0,0 +1,25 @@
1
+ //#region src/realtime/broadcast-hub.ts
2
+ /**
3
+ * 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.
5
+ */
6
+ const broadcastHub = () => {
7
+ const topics = /* @__PURE__ */ new Map();
8
+ return {
9
+ /** Puts the member on the topic until the returned function takes it off. */
10
+ join: (topic, member) => {
11
+ const members = topics.get(topic) ?? /* @__PURE__ */ new Set();
12
+ topics.set(topic, members.add(member));
13
+ return () => {
14
+ members.delete(member);
15
+ if (members.size === 0 && topics.get(topic) === members) topics.delete(topic);
16
+ };
17
+ },
18
+ /** Sends a broadcast to the topic's members, the sender only when it hears its own. */
19
+ send: (topic, from, event, payload) => {
20
+ for (const member of topics.get(topic) ?? []) if (member !== from || member.self) member.receive(event, payload);
21
+ }
22
+ };
23
+ };
24
+ //#endregion
25
+ export { broadcastHub };
@@ -0,0 +1,59 @@
1
+ //#region src/realtime/presence-hub.ts
2
+ const stateOf = (tracked) => {
3
+ const state = Object.create(null);
4
+ for (const [key, meta] of tracked) (state[key] ??= { metas: [] }).metas.push(meta);
5
+ return state;
6
+ };
7
+ /**
8
+ * The presences of a process by topic, in-process like broadcasts: each joined channel tracks at most one, under its
9
+ * key, and channels that listen hear every join and leave on their topic, their own included, as Phoenix Presence
10
+ * sends them. Tracking again replaces the presence: a leave of the old one and a join of the new. Refs count up, unique
11
+ * in the process.
12
+ */
13
+ const presenceHub = () => {
14
+ const topics = /* @__PURE__ */ new Map();
15
+ let lastRef = 0;
16
+ const tell = (members, diff) => {
17
+ for (const member of members.keys()) if (member.listens) member.receive(diff);
18
+ };
19
+ return {
20
+ /** Puts the member on the topic, with what it may do there until it leaves. */
21
+ join: (topic, member) => {
22
+ const members = topics.get(topic) ?? /* @__PURE__ */ new Map();
23
+ topics.set(topic, members.set(member, void 0));
24
+ const untrack = () => {
25
+ const old = members.get(member);
26
+ if (!old) return;
27
+ members.set(member, void 0);
28
+ tell(members, {
29
+ joins: {},
30
+ leaves: stateOf([[member.key, old]])
31
+ });
32
+ };
33
+ return {
34
+ /** The topic's presences now, for `presence_state`. */
35
+ state: () => stateOf([...members].flatMap(([{ key }, meta]) => meta ? [[key, meta]] : [])),
36
+ track: (payload) => {
37
+ const old = members.get(member);
38
+ const meta = {
39
+ ...payload,
40
+ phx_ref: String(++lastRef),
41
+ ...old && { phx_ref_prev: old.phx_ref }
42
+ };
43
+ members.set(member, meta);
44
+ tell(members, {
45
+ joins: stateOf([[member.key, meta]]),
46
+ leaves: old ? stateOf([[member.key, old]]) : {}
47
+ });
48
+ },
49
+ untrack,
50
+ leave: () => {
51
+ untrack();
52
+ members.delete(member);
53
+ if (members.size === 0 && topics.get(topic) === members) topics.delete(topic);
54
+ }
55
+ };
56
+ } };
57
+ };
58
+ //#endregion
59
+ export { presenceHub };
@@ -4,7 +4,7 @@ import { realtimeRoutes } from "./realtime-routes.js";
4
4
  //#region src/realtime/realtime-app.ts
5
5
  /**
6
6
  * `/realtime/v1`: the `postgres_changes` of the live resources, each checked under the subscriber's role, claims and
7
- * the tables' RLS policies before it is sent.
7
+ * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process.
8
8
  */
9
9
  const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
10
10
  const live = deps.registry.entries.filter((entry) => entry.model.live);
@@ -1,5 +1,7 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { broadcastHub } from "./broadcast-hub.js";
2
3
  import { vsnOf } from "./phoenix-message.js";
4
+ import { presenceHub } from "./presence-hub.js";
3
5
  import { realtimeSocket } from "./realtime-socket.js";
4
6
  import { Hono } from "hono";
5
7
  //#region src/realtime/realtime-routes.ts
@@ -9,10 +11,13 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
9
11
  } });
10
12
  /**
11
13
  * `GET /realtime/v1/websocket?apikey=…&vsn=…`: Supabase Realtime's socket, for `postgres_changes` of the live
12
- * resources (see `realtimeSocket`). A missing or wrong key is refused before the upgrade, with 401.
14
+ * 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.
13
16
  */
14
17
  const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
15
18
  const app = new Hono();
19
+ const broadcasts = broadcastHub();
20
+ const presences = presenceHub();
16
21
  let lastId = 0;
17
22
  const nextId = () => ++lastId;
18
23
  app.get("/websocket", async (c, next) => {
@@ -31,12 +36,14 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
31
36
  resolve: (token) => caller(keyRequest(apikey, token)),
32
37
  live,
33
38
  hub,
39
+ broadcasts,
40
+ presences,
34
41
  nextId,
35
42
  report
36
43
  });
37
44
  },
38
45
  onMessage: (event) => {
39
- if (typeof event.data === "string") socket?.message(event.data);
46
+ if (typeof event.data === "string" || event.data instanceof ArrayBuffer) socket?.message(event.data);
40
47
  },
41
48
  onClose: () => socket?.closed()
42
49
  };
@@ -1,6 +1,8 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  import { parseBindings } from "./postgres-changes.js";
3
3
  import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
4
+ import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
5
+ import { randomUUID } from "node:crypto";
4
6
  //#region src/realtime/realtime-socket.ts
5
7
  const tokenOf = (payload) => typeof payload.access_token === "string" && payload.access_token !== "" ? payload.access_token : void 0;
6
8
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -10,11 +12,11 @@ const refusal = (error) => {
10
12
  };
11
13
  /**
12
14
  * One WebSocket of `/realtime/v1`, speaking Phoenix channels as realtime-js expects: heartbeats, channel joins with
13
- * `postgres_changes` bindings and an access token, token refreshes on an open channel, and leaves. A channel's token
14
- * ends it when it expires, unless a newer one came first. Messages are handled one at a time, in order. Broadcast,
15
- * presence and private channels are refused for now.
15
+ * `postgres_changes` bindings and an access token, token refreshes on an open channel, broadcasts with their `self`
16
+ * and `ack` options, presence tracking, and leaves. A channel's token ends it when it expires, unless a newer one came
17
+ * first. Messages are handled one at a time, in order. Private channels are refused for now.
16
18
  */
17
- const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report, idleMs = 6e4, now = Date.now }) => {
19
+ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, broadcasts, presences, nextId, report, idleMs = 6e4, now = Date.now }) => {
18
20
  const channels = /* @__PURE__ */ new Map();
19
21
  let queue = Promise.resolve();
20
22
  let idle;
@@ -26,6 +28,15 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
26
28
  event,
27
29
  payload
28
30
  });
31
+ const hear = (topic, channel, event, payload) => {
32
+ if (vsn === "2.0.0") return send(encodeBroadcast(topic, event, payload));
33
+ if (payload instanceof ArrayBuffer) return;
34
+ toChannel(topic, channel, "broadcast", {
35
+ type: "broadcast",
36
+ event,
37
+ payload
38
+ });
39
+ };
29
40
  const leave = (topic) => {
30
41
  const channel = channels.get(topic);
31
42
  if (!channel) return;
@@ -54,24 +65,46 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
54
65
  leave(message.topic);
55
66
  const config = isRecord(message.payload.config) ? message.payload.config : {};
56
67
  if (config.private === true) return push(reply(message, "error", { reason: "Not implemented: private channels" }));
57
- if (isRecord(config.presence) && config.presence.enabled === true) return push(reply(message, "error", { reason: "Not implemented: presence" }));
68
+ const broadcast = isRecord(config.broadcast) ? config.broadcast : {};
69
+ const presence = isRecord(config.presence) ? config.presence : {};
58
70
  const caller = await resolve(tokenOf(message.payload)).catch(refusal);
59
71
  if (typeof caller === "string") return push(reply(message, "error", { reason: caller }));
60
72
  const parsed = parseBindings(config.postgres_changes, live, nextId);
61
73
  if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
74
+ const { topic } = message;
75
+ const member = {
76
+ self: broadcast.self === true,
77
+ receive: (event, payload) => hear(topic, channel, event, payload)
78
+ };
79
+ const key = typeof presence.key === "string" && presence.key !== "" ? presence.key : randomUUID();
80
+ const listens = presence.enabled === true;
81
+ const seat = presences.join(topic, {
82
+ key,
83
+ listens,
84
+ receive: (diff) => toChannel(topic, channel, "presence_diff", diff)
85
+ });
86
+ const offBroadcast = broadcasts.join(topic, member);
87
+ const offChanges = parsed.bindings.length > 0 ? hub.add({
88
+ caller: () => channel.caller,
89
+ bindings: parsed.bindings,
90
+ send: (payload) => toChannel(topic, channel, "postgres_changes", payload)
91
+ }) : () => {};
62
92
  const channel = {
63
93
  joinRef: message.joinRef,
64
94
  caller,
65
- remove: () => {}
95
+ ack: broadcast.ack === true,
96
+ member,
97
+ presence: seat,
98
+ remove: () => {
99
+ offChanges();
100
+ offBroadcast();
101
+ seat.leave();
102
+ }
66
103
  };
67
- if (parsed.bindings.length > 0) channel.remove = hub.add({
68
- caller: () => channel.caller,
69
- bindings: parsed.bindings,
70
- send: (payload) => toChannel(message.topic, channel, "postgres_changes", payload)
71
- });
72
- channels.set(message.topic, channel);
73
- expireAt(message.topic, channel);
104
+ channels.set(topic, channel);
105
+ expireAt(topic, channel);
74
106
  push(reply(message, "ok", { postgres_changes: parsed.replies }));
107
+ if (listens) toChannel(topic, channel, "presence_state", seat.state());
75
108
  if (parsed.bindings.length > 0) toChannel(message.topic, channel, "system", {
76
109
  extension: "postgres_changes",
77
110
  status: "ok",
@@ -87,14 +120,31 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
87
120
  channel.caller = caller;
88
121
  expireAt(message.topic, channel);
89
122
  };
123
+ const sendBroadcast = (message, channel) => {
124
+ const { event, payload } = message.payload;
125
+ if (typeof event !== "string") return push(reply(message, "error", { reason: "A broadcast needs an event" }));
126
+ 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);
128
+ if (channel.ack) push(reply(message, "ok", {}));
129
+ };
130
+ const updatePresence = (message, channel) => {
131
+ const { event, payload } = message.payload;
132
+ if (event === "track" && isRecord(payload)) channel.presence.track(payload);
133
+ else if (event === "untrack") channel.presence.untrack();
134
+ else return push(reply(message, "error", { reason: "A presence message tracks an object or untracks" }));
135
+ push(reply(message, "ok", {}));
136
+ };
90
137
  const handle = async (message) => {
91
138
  if (message.topic === "phoenix") {
92
139
  if (message.event === "heartbeat") push(reply(message, "ok", {}));
93
140
  return;
94
141
  }
95
142
  if (message.event === "phx_join") return join(message);
96
- if (!channels.has(message.topic)) return push(reply(message, "error", { reason: "unmatched topic" }));
143
+ const channel = channels.get(message.topic);
144
+ if (!channel) return push(reply(message, "error", { reason: "unmatched topic" }));
97
145
  if (message.event === "access_token") return refresh(message);
146
+ if (message.event === "broadcast") return sendBroadcast(message, channel);
147
+ if (message.event === "presence") return updatePresence(message, channel);
98
148
  if (message.event === "phx_leave") {
99
149
  leave(message.topic);
100
150
  push(reply(message, "ok", {}));
@@ -106,7 +156,7 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
106
156
  payload: {}
107
157
  });
108
158
  }
109
- push(reply(message, "error", { reason: `Not implemented: ${message.event}; this server has postgres_changes only` }));
159
+ push(reply(message, "error", { reason: `Not implemented: ${message.event}` }));
110
160
  };
111
161
  const stayAwake = () => {
112
162
  clearTimeout(idle);
@@ -114,10 +164,10 @@ const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report,
114
164
  };
115
165
  stayAwake();
116
166
  return {
117
- /** Handles a text frame after those before it. */
167
+ /** Handles a frame after those before it: text, or binary for a broadcast. */
118
168
  message: (frame) => {
119
169
  stayAwake();
120
- const message = decodeMessage(vsn, frame);
170
+ const message = typeof frame === "string" ? decodeMessage(vsn, frame) : decodeBroadcastPush(frame);
121
171
  if (!message) return;
122
172
  queue = queue.then(() => handle(message)).catch((error) => {
123
173
  report(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
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": {
@@ -32,9 +32,9 @@
32
32
  "dependencies": {
33
33
  "@better-auth/passkey": "^1.7.7",
34
34
  "@better-auth/sso": "^1.7.7",
35
- "@protobase/layout": "0.26.1",
36
- "@protobase/query": "0.26.1",
37
- "@protobase/schema": "0.26.1",
35
+ "@protobase/layout": "0.26.3",
36
+ "@protobase/query": "0.26.3",
37
+ "@protobase/schema": "0.26.3",
38
38
  "@react-email/render": "^2.1.0",
39
39
  "@scalar/hono-api-reference": "^0.12.10",
40
40
  "@sentry/core": "^11.7.0",