@rebasepro/server 0.9.1-canary.97e305f → 0.9.1-canary.a639738
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/env.d.ts +3 -0
- package/dist/index.es.js +383 -37
- package/dist/index.es.js.map +1 -1
- package/dist/init.d.ts +21 -0
- package/dist/storage/LocalStorageController.d.ts +19 -2
- package/dist/storage/routes.d.ts +8 -1
- package/dist/storage/tus-handler.d.ts +21 -1
- package/dist/storage/types.d.ts +33 -0
- package/package.json +7 -6
package/dist/env.d.ts
CHANGED
|
@@ -55,6 +55,9 @@ declare const rebaseEnvSchema: z.ZodObject<{
|
|
|
55
55
|
true: "true";
|
|
56
56
|
false: "false";
|
|
57
57
|
}>>, z.ZodTransform<boolean, "" | "true" | "false" | undefined>>;
|
|
58
|
+
GCS_BUCKET: z.ZodOptional<z.ZodString>;
|
|
59
|
+
GCS_PROJECT_ID: z.ZodOptional<z.ZodString>;
|
|
60
|
+
GCS_KEY_FILENAME: z.ZodOptional<z.ZodString>;
|
|
58
61
|
}, z.core.$strip>;
|
|
59
62
|
/** Inferred type of the validated environment. */
|
|
60
63
|
export type RebaseEnv = z.infer<typeof rebaseEnvSchema>;
|
package/dist/index.es.js
CHANGED
|
@@ -11623,11 +11623,21 @@ var LocalStorageController = class {
|
|
|
11623
11623
|
}
|
|
11624
11624
|
}
|
|
11625
11625
|
/**
|
|
11626
|
-
* Get the full filesystem path for a storage path
|
|
11627
|
-
*
|
|
11626
|
+
* Get the full filesystem path for a storage path, with a traversal guard
|
|
11627
|
+
* that keeps the result inside the bucket directory.
|
|
11628
|
+
*
|
|
11629
|
+
* Defaults the bucket the way `putObject` does.
|
|
11630
|
+
*
|
|
11631
|
+
* `putObject` has always written into `default` when given no bucket, while
|
|
11632
|
+
* the read side resolved a bare key against the storage root — where
|
|
11633
|
+
* nothing is. The two disagreed silently: `getObject` returned null (reads
|
|
11634
|
+
* as "file missing"), `deleteObject` deleted nothing (404s are swallowed by
|
|
11635
|
+
* design), and `listObjects` returned an empty page. So the obvious
|
|
11636
|
+
* `putObject({ key })` → `getObject(key)` did not round-trip and nothing
|
|
11637
|
+
* said why. One default, applied everywhere, removes the whole class.
|
|
11628
11638
|
*/
|
|
11629
11639
|
getFullPath(storagePath, bucket) {
|
|
11630
|
-
const bucketPath =
|
|
11640
|
+
const bucketPath = path$1.join(this.basePath, bucket ?? "default");
|
|
11631
11641
|
const resolved = path$1.resolve(path$1.join(bucketPath, storagePath));
|
|
11632
11642
|
if (!resolved.startsWith(bucketPath + path$1.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
11633
11643
|
return resolved;
|
|
@@ -11788,8 +11798,10 @@ var LocalStorageController = class {
|
|
|
11788
11798
|
let count = 0;
|
|
11789
11799
|
const maxResults = options?.maxResults ?? 1e3;
|
|
11790
11800
|
const startIndex = options?.pageToken ? parseInt(options.pageToken, 10) : 0;
|
|
11801
|
+
let scanned = startIndex;
|
|
11791
11802
|
for (let i = startIndex; i < entries.length && count < maxResults; i++) {
|
|
11792
11803
|
const entry = entries[i];
|
|
11804
|
+
scanned = i + 1;
|
|
11793
11805
|
if (entry.name.endsWith(".metadata.json")) continue;
|
|
11794
11806
|
const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
11795
11807
|
const bucket = options?.bucket ?? "default";
|
|
@@ -11808,7 +11820,7 @@ var LocalStorageController = class {
|
|
|
11808
11820
|
return {
|
|
11809
11821
|
items,
|
|
11810
11822
|
prefixes,
|
|
11811
|
-
nextPageToken:
|
|
11823
|
+
nextPageToken: scanned < entries.length ? String(scanned) : void 0
|
|
11812
11824
|
};
|
|
11813
11825
|
} catch (error) {
|
|
11814
11826
|
const code = error?.code;
|
|
@@ -13759,12 +13771,14 @@ var UPLOAD_EXPIRY_MS = 1440 * 60 * 1e3;
|
|
|
13759
13771
|
var TusHandler = class {
|
|
13760
13772
|
storageController;
|
|
13761
13773
|
storageRegistry;
|
|
13774
|
+
authorizeUpload;
|
|
13762
13775
|
uploads = /* @__PURE__ */ new Map();
|
|
13763
13776
|
tusDir;
|
|
13764
13777
|
cleanupTimer;
|
|
13765
|
-
constructor(storageBaseDir, storageController, storageRegistry) {
|
|
13778
|
+
constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload) {
|
|
13766
13779
|
this.storageController = storageController;
|
|
13767
13780
|
this.storageRegistry = storageRegistry;
|
|
13781
|
+
this.authorizeUpload = authorizeUpload;
|
|
13768
13782
|
this.tusDir = join(storageBaseDir, ".tus-uploads");
|
|
13769
13783
|
}
|
|
13770
13784
|
/** Ensure the temp directory exists. */
|
|
@@ -13828,6 +13842,10 @@ var TusHandler = class {
|
|
|
13828
13842
|
if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
|
|
13829
13843
|
if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
|
|
13830
13844
|
const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
|
|
13845
|
+
if (this.authorizeUpload) {
|
|
13846
|
+
const key = metadata.key || metadata.filename || "";
|
|
13847
|
+
await this.authorizeUpload(c, key, metadata.bucket || "default");
|
|
13848
|
+
}
|
|
13831
13849
|
const id = randomUUID$1();
|
|
13832
13850
|
const filePath = join(this.tusDir, id);
|
|
13833
13851
|
await writeFile(filePath, Buffer.alloc(0));
|
|
@@ -14035,7 +14053,34 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
|
|
|
14035
14053
|
function createStorageRoutes(config) {
|
|
14036
14054
|
const router = new Hono();
|
|
14037
14055
|
router.onError(errorHandler);
|
|
14038
|
-
const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter } = config;
|
|
14056
|
+
const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize } = config;
|
|
14057
|
+
/**
|
|
14058
|
+
* Run the per-object authorization hook, if one is configured.
|
|
14059
|
+
*
|
|
14060
|
+
* Denials are 403 rather than 404: the route already established that the
|
|
14061
|
+
* caller is authenticated, so hiding existence buys nothing, and a
|
|
14062
|
+
* distinguishable status is what makes a misconfigured policy debuggable.
|
|
14063
|
+
* A hook that throws denies too — an ownership lookup that fails must not
|
|
14064
|
+
* fall open.
|
|
14065
|
+
*/
|
|
14066
|
+
const checkAuthorized = async (c, operation, key, bucket, storageId) => {
|
|
14067
|
+
if (!authorize) return;
|
|
14068
|
+
const user = c.get("user") ?? null;
|
|
14069
|
+
if (user?.userId === "download-token" || user?.userId === "public") return;
|
|
14070
|
+
let allowed;
|
|
14071
|
+
try {
|
|
14072
|
+
allowed = await authorize({
|
|
14073
|
+
key,
|
|
14074
|
+
bucket,
|
|
14075
|
+
operation,
|
|
14076
|
+
user,
|
|
14077
|
+
storageId: storageId ?? void 0
|
|
14078
|
+
});
|
|
14079
|
+
} catch {
|
|
14080
|
+
allowed = false;
|
|
14081
|
+
}
|
|
14082
|
+
if (!allowed) throw ApiError.forbidden("Not authorized for this object");
|
|
14083
|
+
};
|
|
14039
14084
|
/**
|
|
14040
14085
|
* Resolve the storage controller for a request.
|
|
14041
14086
|
* Looks up by `storageId` in the registry, falls back to the single
|
|
@@ -14085,6 +14130,7 @@ function createStorageRoutes(config) {
|
|
|
14085
14130
|
const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
|
|
14086
14131
|
const metadata = {};
|
|
14087
14132
|
for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
|
|
14133
|
+
await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
|
|
14088
14134
|
const result = await resolveController(storageId).putObject({
|
|
14089
14135
|
file: uploadedFile,
|
|
14090
14136
|
key: finalKey,
|
|
@@ -14105,7 +14151,12 @@ function createStorageRoutes(config) {
|
|
|
14105
14151
|
const rawPath = extractWildcardPath(c);
|
|
14106
14152
|
if (!rawPath) throw ApiError.notFound("File not found");
|
|
14107
14153
|
const filePath = decodeURIComponent(rawPath);
|
|
14108
|
-
const
|
|
14154
|
+
const storageId = c.req.query("storageId");
|
|
14155
|
+
const resolved = resolveController(storageId);
|
|
14156
|
+
{
|
|
14157
|
+
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14158
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
14159
|
+
}
|
|
14109
14160
|
const transformOpts = parseTransformOptions(c.req.query());
|
|
14110
14161
|
if (resolved.getType() === "local") {
|
|
14111
14162
|
const localController = resolved;
|
|
@@ -14168,8 +14219,10 @@ function createStorageRoutes(config) {
|
|
|
14168
14219
|
fileNotFound: true
|
|
14169
14220
|
}, 404);
|
|
14170
14221
|
const filePath = decodeURIComponent(rawPath);
|
|
14171
|
-
const
|
|
14222
|
+
const storageId = c.req.query("storageId");
|
|
14223
|
+
const resolved = resolveController(storageId);
|
|
14172
14224
|
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14225
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
14173
14226
|
const downloadConfig = await resolved.getSignedUrl(resolvedPath, bucket);
|
|
14174
14227
|
if (downloadConfig.fileNotFound) throw ApiError.notFound("File not found");
|
|
14175
14228
|
if (downloadConfig.metadata) {
|
|
@@ -14195,8 +14248,10 @@ function createStorageRoutes(config) {
|
|
|
14195
14248
|
message: "No file to delete"
|
|
14196
14249
|
});
|
|
14197
14250
|
const filePath = decodeURIComponent(rawPath);
|
|
14198
|
-
const
|
|
14251
|
+
const storageId = c.req.query("storageId");
|
|
14252
|
+
const resolved = resolveController(storageId);
|
|
14199
14253
|
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14254
|
+
await checkAuthorized(c, "delete", resolvedPath, bucket, storageId);
|
|
14200
14255
|
await resolved.deleteObject(resolvedPath, bucket);
|
|
14201
14256
|
return c.json({
|
|
14202
14257
|
success: true,
|
|
@@ -14211,7 +14266,9 @@ function createStorageRoutes(config) {
|
|
|
14211
14266
|
const bucket = c.req.query("bucket");
|
|
14212
14267
|
const maxResults = c.req.query("maxResults");
|
|
14213
14268
|
const pageToken = c.req.query("pageToken");
|
|
14214
|
-
const
|
|
14269
|
+
const storageId = c.req.query("storageId");
|
|
14270
|
+
const resolved = resolveController(storageId);
|
|
14271
|
+
await checkAuthorized(c, "list", storagePrefix, bucket ?? "default", storageId);
|
|
14215
14272
|
const result = await resolved.listObjects(storagePrefix, {
|
|
14216
14273
|
bucket: bucket ?? (resolved.getType() === "local" ? "default" : void 0),
|
|
14217
14274
|
maxResults: maxResults ? parseInt(maxResults, 10) : void 0,
|
|
@@ -14234,6 +14291,7 @@ function createStorageRoutes(config) {
|
|
|
14234
14291
|
const resolved = resolveController(storageId);
|
|
14235
14292
|
const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
|
|
14236
14293
|
if (!resolvedPath || resolvedPath.trim() === "") throw ApiError.badRequest("Invalid folder path");
|
|
14294
|
+
await checkAuthorized(c, "write", resolvedPath, bucket, storageId);
|
|
14237
14295
|
if (resolved.getType() === "local") {
|
|
14238
14296
|
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
|
|
14239
14297
|
fs$1.mkdirSync(absolutePath, { recursive: true });
|
|
@@ -14251,7 +14309,9 @@ function createStorageRoutes(config) {
|
|
|
14251
14309
|
}, 201);
|
|
14252
14310
|
});
|
|
14253
14311
|
const defaultCtrl = getDefaultController();
|
|
14254
|
-
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry)
|
|
14312
|
+
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
|
|
14313
|
+
await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
|
|
14314
|
+
} : void 0);
|
|
14255
14315
|
tusHandler.startCleanup();
|
|
14256
14316
|
router.options("/tus", (_c) => tusHandler.options());
|
|
14257
14317
|
router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
|
|
@@ -14392,7 +14452,7 @@ async function initializeStorage(storageConfig, isProduction) {
|
|
|
14392
14452
|
const toController = async (entry, label) => {
|
|
14393
14453
|
if (typeof entry.putObject === "function") return entry;
|
|
14394
14454
|
const conf = entry;
|
|
14395
|
-
if (isProduction && conf.type === "local")
|
|
14455
|
+
if (isProduction && conf.type === "local" && !process.env.FORCE_LOCAL_STORAGE) throw new Error(`Storage backend "${label}" is set to "local" in production. Local storage is the container filesystem, so uploaded files are destroyed on the next restart or redeploy. Configure S3-compatible storage (STORAGE_TYPE=s3) or GCS (STORAGE_TYPE=gcs), or pass a custom StorageController. If this deployment really does have a durable volume mounted at the storage path, set FORCE_LOCAL_STORAGE=true to proceed.`);
|
|
14396
14456
|
return await createStorageController(conf);
|
|
14397
14457
|
};
|
|
14398
14458
|
if (typeof storageConfig === "object" && ("type" in storageConfig || typeof storageConfig.putObject === "function")) controllers[DEFAULT_STORAGE_ID] = await toController(storageConfig, DEFAULT_STORAGE_ID);
|
|
@@ -15941,6 +16001,20 @@ function extractMessageError(message) {
|
|
|
15941
16001
|
};
|
|
15942
16002
|
}
|
|
15943
16003
|
/**
|
|
16004
|
+
* Broadcast and presence frames.
|
|
16005
|
+
*
|
|
16006
|
+
* Fire-and-forget (the server sends no response envelope), and exempt from the
|
|
16007
|
+
* client-side auth gate — a public channel is usable without an account.
|
|
16008
|
+
*/
|
|
16009
|
+
var CHANNEL_MESSAGE_TYPES = new Set([
|
|
16010
|
+
"join_channel",
|
|
16011
|
+
"leave_channel",
|
|
16012
|
+
"broadcast",
|
|
16013
|
+
"presence_track",
|
|
16014
|
+
"presence_untrack",
|
|
16015
|
+
"presence_state"
|
|
16016
|
+
]);
|
|
16017
|
+
/**
|
|
15944
16018
|
* Low-level realtime WebSocket client.
|
|
15945
16019
|
*
|
|
15946
16020
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
@@ -15955,6 +16029,36 @@ var RebaseWebSocketClient = class {
|
|
|
15955
16029
|
getAuthToken;
|
|
15956
16030
|
subscriptions = /* @__PURE__ */ new Map();
|
|
15957
16031
|
listeners = /* @__PURE__ */ new Map();
|
|
16032
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
16033
|
+
channelHandlers = /* @__PURE__ */ new Map();
|
|
16034
|
+
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
16035
|
+
closedByCaller = false;
|
|
16036
|
+
/**
|
|
16037
|
+
* Whether a socket exists at all (open or still opening).
|
|
16038
|
+
*
|
|
16039
|
+
* Lets callers distinguish "authenticate the live socket" from "there is
|
|
16040
|
+
* nothing to authenticate yet", without that question forcing a dial.
|
|
16041
|
+
*/
|
|
16042
|
+
get hasSocket() {
|
|
16043
|
+
return this.ws !== null;
|
|
16044
|
+
}
|
|
16045
|
+
/** So the "no WebSocket in this environment" warning is said once, not per call. */
|
|
16046
|
+
warnedNoWebSocket = false;
|
|
16047
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
16048
|
+
onChannelMessage(channel, handler) {
|
|
16049
|
+
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
|
|
16050
|
+
this.channelHandlers.get(channel).add(handler);
|
|
16051
|
+
return () => {
|
|
16052
|
+
const handlers = this.channelHandlers.get(channel);
|
|
16053
|
+
if (!handlers) return;
|
|
16054
|
+
handlers.delete(handler);
|
|
16055
|
+
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
16056
|
+
};
|
|
16057
|
+
}
|
|
16058
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
16059
|
+
onReconnect(handler) {
|
|
16060
|
+
return this.on("reconnect", handler);
|
|
16061
|
+
}
|
|
15958
16062
|
on(event, cb) {
|
|
15959
16063
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
15960
16064
|
this.listeners.get(event).add(cb);
|
|
@@ -15985,8 +16089,25 @@ var RebaseWebSocketClient = class {
|
|
|
15985
16089
|
this.getAuthToken = config.getAuthToken;
|
|
15986
16090
|
this.onUnauthorized = config.onUnauthorized;
|
|
15987
16091
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
|
|
15988
|
-
|
|
15989
|
-
|
|
16092
|
+
}
|
|
16093
|
+
/**
|
|
16094
|
+
* Open the socket if it is not open (or opening) already.
|
|
16095
|
+
*
|
|
16096
|
+
* Idempotent, synchronous, and safe to call on every operation that needs a
|
|
16097
|
+
* live socket — `initWebSocket` already no-ops on an open socket and is
|
|
16098
|
+
* re-entrant, since the reconnect path has always called it.
|
|
16099
|
+
*/
|
|
16100
|
+
ensureConnected() {
|
|
16101
|
+
if (this.closedByCaller) return;
|
|
16102
|
+
if (!this.WebSocketConstructor) {
|
|
16103
|
+
if (!this.warnedNoWebSocket) {
|
|
16104
|
+
this.warnedNoWebSocket = true;
|
|
16105
|
+
console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
|
|
16106
|
+
}
|
|
16107
|
+
return;
|
|
16108
|
+
}
|
|
16109
|
+
if (this.ws || this.reconnectTimeout) return;
|
|
16110
|
+
this.initWebSocket();
|
|
15990
16111
|
}
|
|
15991
16112
|
/**
|
|
15992
16113
|
* Authenticate the WebSocket connection
|
|
@@ -16036,7 +16157,16 @@ var RebaseWebSocketClient = class {
|
|
|
16036
16157
|
});
|
|
16037
16158
|
}
|
|
16038
16159
|
}
|
|
16039
|
-
|
|
16160
|
+
/**
|
|
16161
|
+
* Drop the socket.
|
|
16162
|
+
*
|
|
16163
|
+
* `permanent` distinguishes the two callers. Signing out drops the socket
|
|
16164
|
+
* but the client stays usable — a later subscribe should reconnect
|
|
16165
|
+
* anonymously. `client.close()` is the caller saying they are done, and
|
|
16166
|
+
* must not be undone by a stray queued frame.
|
|
16167
|
+
*/
|
|
16168
|
+
disconnect(permanent = false) {
|
|
16169
|
+
if (permanent) this.closedByCaller = true;
|
|
16040
16170
|
this.isAuthenticated = false;
|
|
16041
16171
|
this.authPromise = null;
|
|
16042
16172
|
if (this.reconnectTimeout) {
|
|
@@ -16221,6 +16351,15 @@ var RebaseWebSocketClient = class {
|
|
|
16221
16351
|
}
|
|
16222
16352
|
return;
|
|
16223
16353
|
}
|
|
16354
|
+
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
|
|
16355
|
+
const handlers = this.channelHandlers.get(message.channel);
|
|
16356
|
+
if (handlers) for (const handler of [...handlers]) try {
|
|
16357
|
+
handler(message);
|
|
16358
|
+
} catch (error) {
|
|
16359
|
+
console.error("Error in channel handler:", error);
|
|
16360
|
+
}
|
|
16361
|
+
return;
|
|
16362
|
+
}
|
|
16224
16363
|
if (subscriptionId && type === "collection_update") {
|
|
16225
16364
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
16226
16365
|
if (subscriptionKey) {
|
|
@@ -16409,21 +16548,28 @@ var RebaseWebSocketClient = class {
|
|
|
16409
16548
|
throw error;
|
|
16410
16549
|
}
|
|
16411
16550
|
}
|
|
16551
|
+
/**
|
|
16552
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
16553
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
16554
|
+
*/
|
|
16412
16555
|
sendMessage(message) {
|
|
16413
16556
|
const queuedMsg = message;
|
|
16414
16557
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
|
|
16415
|
-
if (!this.isConnected || !this.ws)
|
|
16416
|
-
|
|
16417
|
-
|
|
16418
|
-
|
|
16419
|
-
|
|
16420
|
-
|
|
16558
|
+
if (!this.isConnected || !this.ws) {
|
|
16559
|
+
this.ensureConnected();
|
|
16560
|
+
return new Promise((resolve, reject) => {
|
|
16561
|
+
const queueable = message;
|
|
16562
|
+
queueable._queuedResolve = resolve;
|
|
16563
|
+
queueable._queuedReject = reject;
|
|
16564
|
+
this.messageQueue.push(message);
|
|
16565
|
+
});
|
|
16566
|
+
}
|
|
16421
16567
|
return new Promise((resolve, reject) => {
|
|
16422
16568
|
this.doSendMessage(message, resolve, reject);
|
|
16423
16569
|
});
|
|
16424
16570
|
}
|
|
16425
16571
|
async doSendMessage(message, resolve, reject) {
|
|
16426
|
-
if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
|
|
16572
|
+
if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
|
|
16427
16573
|
await this.ensureAuthenticated();
|
|
16428
16574
|
} catch (error) {
|
|
16429
16575
|
reject(new RebaseApiError(error instanceof Error ? error.message : "Authentication required"));
|
|
@@ -16431,17 +16577,7 @@ var RebaseWebSocketClient = class {
|
|
|
16431
16577
|
}
|
|
16432
16578
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
16433
16579
|
message.requestId = requestId;
|
|
16434
|
-
const expectsResponse = !
|
|
16435
|
-
"subscribe_collection",
|
|
16436
|
-
"subscribe_one",
|
|
16437
|
-
"unsubscribe",
|
|
16438
|
-
"join_channel",
|
|
16439
|
-
"leave_channel",
|
|
16440
|
-
"broadcast",
|
|
16441
|
-
"presence_track",
|
|
16442
|
-
"presence_untrack",
|
|
16443
|
-
"presence_state"
|
|
16444
|
-
].includes(message.type);
|
|
16580
|
+
const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
|
|
16445
16581
|
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
16446
16582
|
const timeoutHandle = setTimeout(() => {
|
|
16447
16583
|
if (this.pendingRequests.has(requestId)) {
|
|
@@ -16670,6 +16806,7 @@ var RebaseWebSocketClient = class {
|
|
|
16670
16806
|
});
|
|
16671
16807
|
}
|
|
16672
16808
|
listenCollection(props, onUpdate, onError) {
|
|
16809
|
+
this.ensureConnected();
|
|
16673
16810
|
const subscriptionKey = this.createCollectionSubscriptionKey(props);
|
|
16674
16811
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
16675
16812
|
const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
@@ -16731,6 +16868,7 @@ var RebaseWebSocketClient = class {
|
|
|
16731
16868
|
};
|
|
16732
16869
|
}
|
|
16733
16870
|
listenOne(props, onUpdate, onError) {
|
|
16871
|
+
this.ensureConnected();
|
|
16734
16872
|
const subscriptionKey = this.createSingleSubscriptionKey(props);
|
|
16735
16873
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
16736
16874
|
const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
|
|
@@ -16989,6 +17127,188 @@ var RebaseWebSocketClient = class {
|
|
|
16989
17127
|
}
|
|
16990
17128
|
};
|
|
16991
17129
|
/**
|
|
17130
|
+
* Re-send presence comfortably inside the server's 30s expiry.
|
|
17131
|
+
*
|
|
17132
|
+
* Two-thirds of the window: one lost heartbeat still leaves time for the next
|
|
17133
|
+
* before the entry is reaped, so a single dropped frame is not a disappearance.
|
|
17134
|
+
*/
|
|
17135
|
+
var PRESENCE_HEARTBEAT_MS = 2e4;
|
|
17136
|
+
var RebaseRealtimeChannel = class {
|
|
17137
|
+
name;
|
|
17138
|
+
transport;
|
|
17139
|
+
presenceHandlers = /* @__PURE__ */ new Set();
|
|
17140
|
+
broadcastHandlers = /* @__PURE__ */ new Set();
|
|
17141
|
+
unsubscribers = [];
|
|
17142
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
17143
|
+
presences = {};
|
|
17144
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
17145
|
+
trackedState = null;
|
|
17146
|
+
heartbeat = null;
|
|
17147
|
+
joined = false;
|
|
17148
|
+
constructor(name, transport) {
|
|
17149
|
+
this.name = name;
|
|
17150
|
+
this.transport = transport;
|
|
17151
|
+
}
|
|
17152
|
+
/**
|
|
17153
|
+
* Join the channel and ask for the current roster.
|
|
17154
|
+
*
|
|
17155
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
17156
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
17157
|
+
* before there is anything to send.
|
|
17158
|
+
*/
|
|
17159
|
+
async join() {
|
|
17160
|
+
if (this.joined) return;
|
|
17161
|
+
this.joined = true;
|
|
17162
|
+
this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
|
|
17163
|
+
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
17164
|
+
this.rejoin();
|
|
17165
|
+
}));
|
|
17166
|
+
await this.transport.sendMessage({
|
|
17167
|
+
type: "join_channel",
|
|
17168
|
+
channel: this.name
|
|
17169
|
+
});
|
|
17170
|
+
await this.transport.sendMessage({
|
|
17171
|
+
type: "presence_state",
|
|
17172
|
+
channel: this.name
|
|
17173
|
+
});
|
|
17174
|
+
}
|
|
17175
|
+
async rejoin() {
|
|
17176
|
+
try {
|
|
17177
|
+
await this.transport.sendMessage({
|
|
17178
|
+
type: "join_channel",
|
|
17179
|
+
channel: this.name
|
|
17180
|
+
});
|
|
17181
|
+
await this.transport.sendMessage({
|
|
17182
|
+
type: "presence_state",
|
|
17183
|
+
channel: this.name
|
|
17184
|
+
});
|
|
17185
|
+
if (this.trackedState) await this.transport.sendMessage({
|
|
17186
|
+
type: "presence_track",
|
|
17187
|
+
channel: this.name,
|
|
17188
|
+
state: this.trackedState
|
|
17189
|
+
});
|
|
17190
|
+
} catch {}
|
|
17191
|
+
}
|
|
17192
|
+
/**
|
|
17193
|
+
* Publish this client's presence state, and keep publishing it.
|
|
17194
|
+
*
|
|
17195
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
17196
|
+
* which is how you update e.g. a cursor position.
|
|
17197
|
+
*/
|
|
17198
|
+
async track(state) {
|
|
17199
|
+
await this.join();
|
|
17200
|
+
this.trackedState = state;
|
|
17201
|
+
await this.transport.sendMessage({
|
|
17202
|
+
type: "presence_track",
|
|
17203
|
+
channel: this.name,
|
|
17204
|
+
state
|
|
17205
|
+
});
|
|
17206
|
+
if (!this.heartbeat) {
|
|
17207
|
+
this.heartbeat = setInterval(() => {
|
|
17208
|
+
if (!this.trackedState) return;
|
|
17209
|
+
this.transport.sendMessage({
|
|
17210
|
+
type: "presence_track",
|
|
17211
|
+
channel: this.name,
|
|
17212
|
+
state: this.trackedState
|
|
17213
|
+
}).catch(() => {});
|
|
17214
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
17215
|
+
this.heartbeat.unref?.();
|
|
17216
|
+
}
|
|
17217
|
+
}
|
|
17218
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
17219
|
+
async untrack() {
|
|
17220
|
+
this.stopHeartbeat();
|
|
17221
|
+
this.trackedState = null;
|
|
17222
|
+
if (this.joined) await this.transport.sendMessage({
|
|
17223
|
+
type: "presence_untrack",
|
|
17224
|
+
channel: this.name
|
|
17225
|
+
});
|
|
17226
|
+
}
|
|
17227
|
+
/**
|
|
17228
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
17229
|
+
* known, then on every change.
|
|
17230
|
+
*/
|
|
17231
|
+
onPresence(handler) {
|
|
17232
|
+
this.presenceHandlers.add(handler);
|
|
17233
|
+
this.join();
|
|
17234
|
+
if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
|
|
17235
|
+
return () => this.presenceHandlers.delete(handler);
|
|
17236
|
+
}
|
|
17237
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
17238
|
+
async broadcast(event, payload) {
|
|
17239
|
+
await this.join();
|
|
17240
|
+
await this.transport.sendMessage({
|
|
17241
|
+
type: "broadcast",
|
|
17242
|
+
channel: this.name,
|
|
17243
|
+
event,
|
|
17244
|
+
payload
|
|
17245
|
+
});
|
|
17246
|
+
}
|
|
17247
|
+
onBroadcast(eventOrHandler, maybeHandler) {
|
|
17248
|
+
const wrapped = typeof eventOrHandler === "string" ? (e) => {
|
|
17249
|
+
if (e.event === eventOrHandler) maybeHandler(e.payload);
|
|
17250
|
+
} : eventOrHandler;
|
|
17251
|
+
this.broadcastHandlers.add(wrapped);
|
|
17252
|
+
this.join();
|
|
17253
|
+
return () => this.broadcastHandlers.delete(wrapped);
|
|
17254
|
+
}
|
|
17255
|
+
/** Leave the channel and release every listener and timer. */
|
|
17256
|
+
async leave() {
|
|
17257
|
+
this.stopHeartbeat();
|
|
17258
|
+
this.trackedState = null;
|
|
17259
|
+
this.presences = {};
|
|
17260
|
+
this.presenceHandlers.clear();
|
|
17261
|
+
this.broadcastHandlers.clear();
|
|
17262
|
+
for (const off of this.unsubscribers) off();
|
|
17263
|
+
this.unsubscribers = [];
|
|
17264
|
+
if (this.joined) {
|
|
17265
|
+
this.joined = false;
|
|
17266
|
+
await this.transport.sendMessage({
|
|
17267
|
+
type: "leave_channel",
|
|
17268
|
+
channel: this.name
|
|
17269
|
+
});
|
|
17270
|
+
}
|
|
17271
|
+
}
|
|
17272
|
+
stopHeartbeat() {
|
|
17273
|
+
if (this.heartbeat) {
|
|
17274
|
+
clearInterval(this.heartbeat);
|
|
17275
|
+
this.heartbeat = null;
|
|
17276
|
+
}
|
|
17277
|
+
}
|
|
17278
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
17279
|
+
handle(message) {
|
|
17280
|
+
switch (message.type) {
|
|
17281
|
+
case "presence_state":
|
|
17282
|
+
this.presences = message.presences ?? {};
|
|
17283
|
+
this.emitPresence();
|
|
17284
|
+
break;
|
|
17285
|
+
case "presence_diff": {
|
|
17286
|
+
const joins = message.joins ?? {};
|
|
17287
|
+
const leaves = message.leaves ?? {};
|
|
17288
|
+
for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
|
|
17289
|
+
for (const id of Object.keys(leaves)) delete this.presences[id];
|
|
17290
|
+
this.emitPresence({
|
|
17291
|
+
joins,
|
|
17292
|
+
leaves
|
|
17293
|
+
});
|
|
17294
|
+
break;
|
|
17295
|
+
}
|
|
17296
|
+
case "broadcast": {
|
|
17297
|
+
const event = {
|
|
17298
|
+
event: message.event,
|
|
17299
|
+
payload: message.payload
|
|
17300
|
+
};
|
|
17301
|
+
for (const handler of this.broadcastHandlers) handler(event);
|
|
17302
|
+
break;
|
|
17303
|
+
}
|
|
17304
|
+
}
|
|
17305
|
+
}
|
|
17306
|
+
emitPresence(diff) {
|
|
17307
|
+
const snapshot = { ...this.presences };
|
|
17308
|
+
for (const handler of this.presenceHandlers) handler(snapshot, diff);
|
|
17309
|
+
}
|
|
17310
|
+
};
|
|
17311
|
+
/**
|
|
16992
17312
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
16993
17313
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
16994
17314
|
*/
|
|
@@ -17037,6 +17357,8 @@ function createRebaseClient(options) {
|
|
|
17037
17357
|
};
|
|
17038
17358
|
const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
17039
17359
|
let ws;
|
|
17360
|
+
/** One channel object per name — see `realtime.channel`. */
|
|
17361
|
+
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
17040
17362
|
if (resolvedWsUrl) {
|
|
17041
17363
|
ws = new RebaseWebSocketClient({
|
|
17042
17364
|
websocketUrl: resolvedWsUrl,
|
|
@@ -17060,7 +17382,7 @@ function createRebaseClient(options) {
|
|
|
17060
17382
|
if (!ws) return;
|
|
17061
17383
|
if (event === "SIGNED_OUT") ws.disconnect();
|
|
17062
17384
|
else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
17063
|
-
if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
|
|
17385
|
+
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
17064
17386
|
}
|
|
17065
17387
|
});
|
|
17066
17388
|
}
|
|
@@ -17147,6 +17469,24 @@ function createRebaseClient(options) {
|
|
|
17147
17469
|
createStorageSource,
|
|
17148
17470
|
fetchStorageSources,
|
|
17149
17471
|
ws,
|
|
17472
|
+
realtime: {
|
|
17473
|
+
/**
|
|
17474
|
+
* Join a broadcast/presence channel.
|
|
17475
|
+
*
|
|
17476
|
+
* Repeated calls with the same name return the same channel, so
|
|
17477
|
+
* separate components can attach handlers without each opening its
|
|
17478
|
+
* own membership — and `leave()` from one would otherwise silently
|
|
17479
|
+
* cut off the others.
|
|
17480
|
+
*/
|
|
17481
|
+
channel: (name) => {
|
|
17482
|
+
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
17483
|
+
let existing = realtimeChannels.get(name);
|
|
17484
|
+
if (!existing) {
|
|
17485
|
+
existing = new RebaseRealtimeChannel(name, ws);
|
|
17486
|
+
realtimeChannels.set(name, existing);
|
|
17487
|
+
}
|
|
17488
|
+
return existing;
|
|
17489
|
+
} },
|
|
17150
17490
|
/**
|
|
17151
17491
|
* Release the realtime socket and its reconnect timer.
|
|
17152
17492
|
*
|
|
@@ -17155,7 +17495,9 @@ function createRebaseClient(options) {
|
|
|
17155
17495
|
* was never started, and safe to call twice.
|
|
17156
17496
|
*/
|
|
17157
17497
|
close: () => {
|
|
17158
|
-
|
|
17498
|
+
for (const channel of realtimeChannels.values()) channel.leave();
|
|
17499
|
+
realtimeChannels.clear();
|
|
17500
|
+
ws?.disconnect(true);
|
|
17159
17501
|
},
|
|
17160
17502
|
setToken: transport.setToken,
|
|
17161
17503
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
@@ -17796,7 +18138,8 @@ async function _initializeRebaseBackend(config) {
|
|
|
17796
18138
|
registry: storageRegistry,
|
|
17797
18139
|
sources: config.storageSources,
|
|
17798
18140
|
requireAuth: resolveRequireAuth(config.auth),
|
|
17799
|
-
authAdapter
|
|
18141
|
+
authAdapter,
|
|
18142
|
+
authorize: config.storageAuthorize
|
|
17800
18143
|
});
|
|
17801
18144
|
const storageRouter = new Hono();
|
|
17802
18145
|
if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
|
|
@@ -19195,7 +19538,10 @@ var rebaseEnvSchema = object({
|
|
|
19195
19538
|
S3_ACCESS_KEY_ID: string().optional(),
|
|
19196
19539
|
S3_SECRET_ACCESS_KEY: string().optional(),
|
|
19197
19540
|
S3_ENDPOINT: string().url().optional(),
|
|
19198
|
-
S3_FORCE_PATH_STYLE: optionalBoolString
|
|
19541
|
+
S3_FORCE_PATH_STYLE: optionalBoolString,
|
|
19542
|
+
GCS_BUCKET: string().optional(),
|
|
19543
|
+
GCS_PROJECT_ID: string().optional(),
|
|
19544
|
+
GCS_KEY_FILENAME: string().optional()
|
|
19199
19545
|
});
|
|
19200
19546
|
function loadEnv(options) {
|
|
19201
19547
|
const isProduction = process.env.NODE_ENV === "production";
|