@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.
- package/dist/create-admin.js +18 -20
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/jobs/schedule.d.ts +4 -0
- package/dist/jobs/schedule.js +11 -1
- package/dist/realtime/broadcast-frame.js +71 -0
- package/dist/realtime/broadcast-hub.js +25 -0
- package/dist/realtime/presence-hub.js +59 -0
- package/dist/realtime/realtime-app.js +1 -1
- package/dist/realtime/realtime-routes.js +9 -2
- package/dist/realtime/realtime-socket.js +67 -17
- package/package.json +4 -4
package/dist/create-admin.js
CHANGED
|
@@ -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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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 };
|
package/dist/jobs/schedule.js
CHANGED
|
@@ -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
|
|
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,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
ack: broadcast.ack === true,
|
|
96
|
+
member,
|
|
97
|
+
presence: seat,
|
|
98
|
+
remove: () => {
|
|
99
|
+
offChanges();
|
|
100
|
+
offBroadcast();
|
|
101
|
+
seat.leave();
|
|
102
|
+
}
|
|
66
103
|
};
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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.
|
|
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.
|
|
36
|
-
"@protobase/query": "0.26.
|
|
37
|
-
"@protobase/schema": "0.26.
|
|
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",
|