deepspace 0.3.9 → 0.3.11
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/cli.js +96 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.js +283 -126
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +172 -2
- package/dist/server.js +581 -1
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +252 -2
- package/dist/worker.js +588 -1
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- package/scripts/add-feature.cjs +654 -0
package/dist/server.js
CHANGED
|
@@ -174,6 +174,20 @@ function sanitizeSubpath(raw) {
|
|
|
174
174
|
function isKeyWithinPrefix(key, prefix) {
|
|
175
175
|
return key.startsWith(prefix);
|
|
176
176
|
}
|
|
177
|
+
var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
178
|
+
"text/html",
|
|
179
|
+
"application/xhtml+xml",
|
|
180
|
+
"image/svg+xml",
|
|
181
|
+
"application/xml",
|
|
182
|
+
"text/xml",
|
|
183
|
+
"application/javascript",
|
|
184
|
+
"text/javascript",
|
|
185
|
+
"application/ecmascript",
|
|
186
|
+
"text/ecmascript"
|
|
187
|
+
]);
|
|
188
|
+
function normalizeMimeType(raw) {
|
|
189
|
+
return raw.split(";")[0].trim().toLowerCase();
|
|
190
|
+
}
|
|
177
191
|
function createScopedR2Handler(config) {
|
|
178
192
|
const requireAuthForMutations = config.requireAuthForMutations ?? true;
|
|
179
193
|
return async (request, url, bucket, auth) => {
|
|
@@ -263,6 +277,13 @@ async function handleUpload(request, url, bucket, prefix, userId) {
|
|
|
263
277
|
fileName = body.name;
|
|
264
278
|
mimeType = body.mimeType || "application/octet-stream";
|
|
265
279
|
}
|
|
280
|
+
mimeType = normalizeMimeType(mimeType) || "application/octet-stream";
|
|
281
|
+
if (DANGEROUS_MIME_TYPES.has(mimeType)) {
|
|
282
|
+
return Response.json(
|
|
283
|
+
{ error: `Unsupported media type: ${mimeType}` },
|
|
284
|
+
{ status: 415, headers: CORS_HEADERS }
|
|
285
|
+
);
|
|
286
|
+
}
|
|
266
287
|
const requestedKey = url.searchParams.get("key");
|
|
267
288
|
let key;
|
|
268
289
|
if (requestedKey !== null) {
|
|
@@ -327,6 +348,7 @@ async function handleDownload(bucket, key) {
|
|
|
327
348
|
}
|
|
328
349
|
const headers = new Headers();
|
|
329
350
|
headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
|
|
351
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
330
352
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
331
353
|
headers.set("Access-Control-Allow-Origin", "*");
|
|
332
354
|
if (object.customMetadata?.originalName) {
|
|
@@ -588,7 +610,7 @@ function createDeepSpaceAuth(config) {
|
|
|
588
610
|
}
|
|
589
611
|
|
|
590
612
|
// src/server/utils/cron.ts
|
|
591
|
-
var DEFAULT_API_BASE_URL = "https://
|
|
613
|
+
var DEFAULT_API_BASE_URL = "https://api-worker.deep.space";
|
|
592
614
|
function buildCronContext(env, ownerUserId, roomId = "default") {
|
|
593
615
|
const hmacSecret = env.INTERNAL_STORAGE_HMAC_SECRET;
|
|
594
616
|
const apiBase = env.API_BASE_URL || DEFAULT_API_BASE_URL;
|
|
@@ -1577,6 +1599,14 @@ var MSG = {
|
|
|
1577
1599
|
CRON_PAUSE: "cron.pause",
|
|
1578
1600
|
CRON_RESUME: "cron.resume",
|
|
1579
1601
|
CRON_STATUS: "cron.status",
|
|
1602
|
+
// ---- JobRoom ---------------------------------------------------------
|
|
1603
|
+
// Background-job queue with durable, alarm-driven execution. See
|
|
1604
|
+
// server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
|
|
1605
|
+
// the room broadcasts JOB_UPDATE for every status transition.
|
|
1606
|
+
JOB_ENQUEUE: "job.enqueue",
|
|
1607
|
+
JOB_CANCEL: "job.cancel",
|
|
1608
|
+
JOB_RETRY: "job.retry",
|
|
1609
|
+
JOB_UPDATE: "job.update",
|
|
1580
1610
|
// ---- PresenceRoom ----------------------------------------------------
|
|
1581
1611
|
PRESENCE_SYNC: "presence.sync",
|
|
1582
1612
|
PRESENCE_JOIN: "presence.join",
|
|
@@ -2069,6 +2099,17 @@ var serverBuild = {
|
|
|
2069
2099
|
cronTasks: (tasks) => ({ type: MSG.CRON_TASKS, payload: { tasks } }),
|
|
2070
2100
|
cronHistory: (history) => ({ type: MSG.CRON_HISTORY, payload: { history } }),
|
|
2071
2101
|
cronStatus: (tasks, recentHistory) => ({ type: MSG.CRON_STATUS, payload: { tasks, recentHistory } }),
|
|
2102
|
+
// Jobs
|
|
2103
|
+
jobSnapshot: (jobs) => ({ type: MSG.JOB_UPDATE, payload: { kind: "snapshot", jobs } }),
|
|
2104
|
+
jobEnqueued: (job, requestId) => ({
|
|
2105
|
+
type: MSG.JOB_UPDATE,
|
|
2106
|
+
payload: { kind: "enqueued", job, requestId }
|
|
2107
|
+
}),
|
|
2108
|
+
jobProgress: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "progress", job } }),
|
|
2109
|
+
jobSucceeded: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "succeeded", job } }),
|
|
2110
|
+
jobFailed: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "failed", job } }),
|
|
2111
|
+
jobCanceled: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "canceled", job } }),
|
|
2112
|
+
jobRetried: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "retried", job } }),
|
|
2072
2113
|
// Presence
|
|
2073
2114
|
presenceSync: (peers) => ({ type: MSG.PRESENCE_SYNC, payload: { peers } }),
|
|
2074
2115
|
presenceJoin: (peer) => ({ type: MSG.PRESENCE_JOIN, payload: { peer } }),
|
|
@@ -5167,6 +5208,543 @@ function cronMatches(parsed, timezone, date) {
|
|
|
5167
5208
|
return parsed.minute.has(minute) && parsed.hour.has(hour) && parsed.dayOfMonth.has(dayOfMonth) && parsed.month.has(month) && parsed.dayOfWeek.has(dayOfWeek);
|
|
5168
5209
|
}
|
|
5169
5210
|
|
|
5211
|
+
// src/server/rooms/job-room.ts
|
|
5212
|
+
var RUNNING_STALE_MS = 16 * 60 * 1e3;
|
|
5213
|
+
var DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1e3;
|
|
5214
|
+
var DEFAULT_SNAPSHOT_LIMIT = 100;
|
|
5215
|
+
var JobRoom = class extends BaseRoom {
|
|
5216
|
+
initialized = false;
|
|
5217
|
+
defaultMaxAttempts;
|
|
5218
|
+
retentionMs;
|
|
5219
|
+
snapshotLimit;
|
|
5220
|
+
retryBackoffMs;
|
|
5221
|
+
/** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */
|
|
5222
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
5223
|
+
/** Set by `ctx.continue` inside `executeJob`; reset before every run. */
|
|
5224
|
+
continueState = null;
|
|
5225
|
+
constructor(state, env, config = {}) {
|
|
5226
|
+
super(state, env);
|
|
5227
|
+
this.defaultMaxAttempts = Math.max(1, config.defaultMaxAttempts ?? 1);
|
|
5228
|
+
this.retentionMs = Math.max(0, config.retentionMs ?? DEFAULT_RETENTION_MS);
|
|
5229
|
+
this.snapshotLimit = Math.max(1, config.snapshotLimit ?? DEFAULT_SNAPSHOT_LIMIT);
|
|
5230
|
+
this.retryBackoffMs = Math.max(0, config.retryBackoffMs ?? 1e3);
|
|
5231
|
+
}
|
|
5232
|
+
ensureInitialized() {
|
|
5233
|
+
if (this.initialized) return;
|
|
5234
|
+
this.initialized = true;
|
|
5235
|
+
this.sql.exec(`
|
|
5236
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
5237
|
+
id TEXT PRIMARY KEY,
|
|
5238
|
+
type TEXT NOT NULL,
|
|
5239
|
+
status TEXT NOT NULL,
|
|
5240
|
+
payload TEXT,
|
|
5241
|
+
result TEXT,
|
|
5242
|
+
error TEXT,
|
|
5243
|
+
progress REAL,
|
|
5244
|
+
progress_message TEXT,
|
|
5245
|
+
checkpoint TEXT,
|
|
5246
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
5247
|
+
max_attempts INTEGER NOT NULL DEFAULT 1,
|
|
5248
|
+
enqueued_at TEXT NOT NULL,
|
|
5249
|
+
started_at TEXT,
|
|
5250
|
+
completed_at TEXT,
|
|
5251
|
+
enqueued_by TEXT,
|
|
5252
|
+
next_run_at TEXT
|
|
5253
|
+
)
|
|
5254
|
+
`);
|
|
5255
|
+
this.sql.exec(
|
|
5256
|
+
`CREATE INDEX IF NOT EXISTS jobs_status_next_run ON jobs (status, next_run_at)`
|
|
5257
|
+
);
|
|
5258
|
+
this.sql.exec(
|
|
5259
|
+
`CREATE INDEX IF NOT EXISTS jobs_completed_at ON jobs (completed_at)`
|
|
5260
|
+
);
|
|
5261
|
+
this.recoverStuckRunning();
|
|
5262
|
+
this.scheduleNextAlarm();
|
|
5263
|
+
}
|
|
5264
|
+
// ==========================================================================
|
|
5265
|
+
// BaseRoom Lifecycle
|
|
5266
|
+
// ==========================================================================
|
|
5267
|
+
async fetch(request) {
|
|
5268
|
+
this.ensureInitialized();
|
|
5269
|
+
return super.fetch(request);
|
|
5270
|
+
}
|
|
5271
|
+
onConnect(ws, user) {
|
|
5272
|
+
this.ensureInitialized();
|
|
5273
|
+
this.sendTo(ws, serverBuild.jobSnapshot(this.getRecentJobs(this.snapshotLimit)));
|
|
5274
|
+
return user;
|
|
5275
|
+
}
|
|
5276
|
+
async onMessage(ws, user, message) {
|
|
5277
|
+
this.ensureInitialized();
|
|
5278
|
+
const { type, payload } = message;
|
|
5279
|
+
switch (type) {
|
|
5280
|
+
case MSG.JOB_ENQUEUE: {
|
|
5281
|
+
const requestId = String(payload.requestId ?? "");
|
|
5282
|
+
const jobType = String(payload.type ?? "");
|
|
5283
|
+
if (!jobType) {
|
|
5284
|
+
this.sendTo(ws, serverBuild.error("JOB_ENQUEUE missing type"));
|
|
5285
|
+
return;
|
|
5286
|
+
}
|
|
5287
|
+
const job = this.insertJob({
|
|
5288
|
+
type: jobType,
|
|
5289
|
+
payload: payload.payload,
|
|
5290
|
+
maxAttempts: typeof payload.maxAttempts === "number" ? Math.max(1, Math.floor(payload.maxAttempts)) : void 0,
|
|
5291
|
+
enqueuedBy: user.userId
|
|
5292
|
+
});
|
|
5293
|
+
this.sendTo(ws, serverBuild.jobEnqueued(job, requestId));
|
|
5294
|
+
this.broadcast(serverBuild.jobEnqueued(job), ws);
|
|
5295
|
+
this.scheduleNextAlarm();
|
|
5296
|
+
break;
|
|
5297
|
+
}
|
|
5298
|
+
case MSG.JOB_CANCEL: {
|
|
5299
|
+
const jobId = String(payload.jobId ?? "");
|
|
5300
|
+
if (!jobId) {
|
|
5301
|
+
this.sendTo(ws, serverBuild.error("JOB_CANCEL missing jobId"));
|
|
5302
|
+
return;
|
|
5303
|
+
}
|
|
5304
|
+
this.cancelJob(jobId);
|
|
5305
|
+
break;
|
|
5306
|
+
}
|
|
5307
|
+
case MSG.JOB_RETRY: {
|
|
5308
|
+
const jobId = String(payload.jobId ?? "");
|
|
5309
|
+
if (!jobId) {
|
|
5310
|
+
this.sendTo(ws, serverBuild.error("JOB_RETRY missing jobId"));
|
|
5311
|
+
return;
|
|
5312
|
+
}
|
|
5313
|
+
this.retryJob(jobId);
|
|
5314
|
+
break;
|
|
5315
|
+
}
|
|
5316
|
+
default:
|
|
5317
|
+
this.sendTo(ws, serverBuild.error(`Unknown job message type: ${type}`));
|
|
5318
|
+
}
|
|
5319
|
+
}
|
|
5320
|
+
async onAlarm() {
|
|
5321
|
+
this.ensureInitialized();
|
|
5322
|
+
await this.drainDueJobs();
|
|
5323
|
+
this.pruneExpired();
|
|
5324
|
+
this.scheduleNextAlarm();
|
|
5325
|
+
}
|
|
5326
|
+
// ==========================================================================
|
|
5327
|
+
// HTTP — internal enqueue path for cross-isolate callers
|
|
5328
|
+
// ==========================================================================
|
|
5329
|
+
/**
|
|
5330
|
+
* One internal endpoint, `POST /enqueue`, used by the `enqueueJob`
|
|
5331
|
+
* helper at the bottom of this file. The room is not meant to be
|
|
5332
|
+
* reachable from the public internet; callers route through the app
|
|
5333
|
+
* worker (or call `this.enqueue(...)` directly from a subclass).
|
|
5334
|
+
*
|
|
5335
|
+
* Subclasses overriding `onRequest` should call `super.onRequest(req)`
|
|
5336
|
+
* for paths they don't handle.
|
|
5337
|
+
*/
|
|
5338
|
+
async onRequest(request) {
|
|
5339
|
+
this.ensureInitialized();
|
|
5340
|
+
const url = new URL(request.url);
|
|
5341
|
+
if (request.method !== "POST" || !url.pathname.endsWith("/enqueue")) {
|
|
5342
|
+
return new Response("Not Found", { status: 404 });
|
|
5343
|
+
}
|
|
5344
|
+
const body = await request.json().catch(() => null);
|
|
5345
|
+
if (!body || typeof body.type !== "string" || !body.type) {
|
|
5346
|
+
return new Response(JSON.stringify({ error: "type required" }), {
|
|
5347
|
+
status: 400,
|
|
5348
|
+
headers: { "Content-Type": "application/json" }
|
|
5349
|
+
});
|
|
5350
|
+
}
|
|
5351
|
+
const job = this.insertJob({
|
|
5352
|
+
type: body.type,
|
|
5353
|
+
payload: body.payload,
|
|
5354
|
+
maxAttempts: typeof body.maxAttempts === "number" ? Math.max(1, Math.floor(body.maxAttempts)) : void 0,
|
|
5355
|
+
enqueuedBy: typeof body.enqueuedBy === "string" ? body.enqueuedBy : void 0
|
|
5356
|
+
});
|
|
5357
|
+
this.broadcast(serverBuild.jobEnqueued(job));
|
|
5358
|
+
this.scheduleNextAlarm();
|
|
5359
|
+
return new Response(JSON.stringify({ job }), {
|
|
5360
|
+
status: 200,
|
|
5361
|
+
headers: { "Content-Type": "application/json" }
|
|
5362
|
+
});
|
|
5363
|
+
}
|
|
5364
|
+
// ==========================================================================
|
|
5365
|
+
// Public enqueue surface (server-side callers in the same isolate)
|
|
5366
|
+
// ==========================================================================
|
|
5367
|
+
/**
|
|
5368
|
+
* Synchronously persist a new job and arm the alarm. Returns the new
|
|
5369
|
+
* job. This is the in-isolate path — useful for a subclass `onJob`
|
|
5370
|
+
* that wants to chain follow-up work without paying a fetch hop:
|
|
5371
|
+
*
|
|
5372
|
+
* protected async onJob(job: Job, ctx: JobContext) {
|
|
5373
|
+
* // ... do work ...
|
|
5374
|
+
* if (needsFollowup) this.enqueue('followup', { parent: job.id })
|
|
5375
|
+
* }
|
|
5376
|
+
*
|
|
5377
|
+
* Workers code that lives outside the DO isolate (HTTP routes, cron
|
|
5378
|
+
* tasks, server actions) cannot call this directly — they must go
|
|
5379
|
+
* through the `enqueueJob` helper at the bottom of this file, which
|
|
5380
|
+
* routes via the DO's `/enqueue` HTTP endpoint.
|
|
5381
|
+
*/
|
|
5382
|
+
enqueue(type, payload, options) {
|
|
5383
|
+
this.ensureInitialized();
|
|
5384
|
+
const job = this.insertJob({
|
|
5385
|
+
type,
|
|
5386
|
+
payload,
|
|
5387
|
+
maxAttempts: options?.maxAttempts,
|
|
5388
|
+
enqueuedBy: options?.enqueuedBy
|
|
5389
|
+
});
|
|
5390
|
+
this.broadcast(serverBuild.jobEnqueued(job));
|
|
5391
|
+
this.scheduleNextAlarm();
|
|
5392
|
+
return job;
|
|
5393
|
+
}
|
|
5394
|
+
// ==========================================================================
|
|
5395
|
+
// Mutation helpers
|
|
5396
|
+
// ==========================================================================
|
|
5397
|
+
insertJob(input) {
|
|
5398
|
+
const id = crypto.randomUUID();
|
|
5399
|
+
const enqueuedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5400
|
+
const maxAttempts = Math.max(1, input.maxAttempts ?? this.defaultMaxAttempts);
|
|
5401
|
+
const payloadJson = input.payload === void 0 ? null : safeStringify(input.payload);
|
|
5402
|
+
this.sql.exec(
|
|
5403
|
+
`INSERT INTO jobs (id, type, status, payload, attempts, max_attempts, enqueued_at, enqueued_by, next_run_at)
|
|
5404
|
+
VALUES (?, ?, 'queued', ?, 0, ?, ?, ?, ?)`,
|
|
5405
|
+
id,
|
|
5406
|
+
input.type,
|
|
5407
|
+
payloadJson,
|
|
5408
|
+
maxAttempts,
|
|
5409
|
+
enqueuedAt,
|
|
5410
|
+
input.enqueuedBy ?? null,
|
|
5411
|
+
enqueuedAt
|
|
5412
|
+
);
|
|
5413
|
+
return this.getJobById(id);
|
|
5414
|
+
}
|
|
5415
|
+
cancelJob(jobId) {
|
|
5416
|
+
const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
|
|
5417
|
+
if (!row) return;
|
|
5418
|
+
if (row.status === "succeeded" || row.status === "failed" || row.status === "canceled") {
|
|
5419
|
+
return;
|
|
5420
|
+
}
|
|
5421
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5422
|
+
this.sql.exec(
|
|
5423
|
+
`UPDATE jobs
|
|
5424
|
+
SET status = 'canceled',
|
|
5425
|
+
completed_at = ?,
|
|
5426
|
+
checkpoint = NULL,
|
|
5427
|
+
next_run_at = NULL
|
|
5428
|
+
WHERE id = ?`,
|
|
5429
|
+
completedAt,
|
|
5430
|
+
jobId
|
|
5431
|
+
);
|
|
5432
|
+
const controller = this.inFlight.get(jobId);
|
|
5433
|
+
if (controller) controller.abort();
|
|
5434
|
+
const job = this.getJobById(jobId);
|
|
5435
|
+
if (job) this.broadcast(serverBuild.jobCanceled(job));
|
|
5436
|
+
}
|
|
5437
|
+
retryJob(jobId) {
|
|
5438
|
+
const row = this.sql.exec(`SELECT status, attempts, max_attempts FROM jobs WHERE id = ?`, jobId).toArray()[0];
|
|
5439
|
+
if (!row) return;
|
|
5440
|
+
if (row.status !== "failed" && row.status !== "canceled") return;
|
|
5441
|
+
const newMax = Math.max(row.max_attempts, row.attempts + 1);
|
|
5442
|
+
this.sql.exec(
|
|
5443
|
+
`UPDATE jobs
|
|
5444
|
+
SET status = 'queued',
|
|
5445
|
+
max_attempts = ?,
|
|
5446
|
+
error = NULL,
|
|
5447
|
+
result = NULL,
|
|
5448
|
+
completed_at = NULL,
|
|
5449
|
+
next_run_at = ?
|
|
5450
|
+
WHERE id = ?`,
|
|
5451
|
+
newMax,
|
|
5452
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
5453
|
+
jobId
|
|
5454
|
+
);
|
|
5455
|
+
const job = this.getJobById(jobId);
|
|
5456
|
+
if (job) this.broadcast(serverBuild.jobRetried(job));
|
|
5457
|
+
this.scheduleNextAlarm();
|
|
5458
|
+
}
|
|
5459
|
+
// ==========================================================================
|
|
5460
|
+
// Alarm / execution
|
|
5461
|
+
// ==========================================================================
|
|
5462
|
+
/**
|
|
5463
|
+
* Drain due jobs in FIFO order. All due jobs share the alarm's
|
|
5464
|
+
* 15-min wall budget (a CF property, not a per-job allocation).
|
|
5465
|
+
*/
|
|
5466
|
+
async drainDueJobs() {
|
|
5467
|
+
const cutoff = (/* @__PURE__ */ new Date()).toISOString();
|
|
5468
|
+
while (true) {
|
|
5469
|
+
const row = this.sql.exec(
|
|
5470
|
+
`SELECT * FROM jobs
|
|
5471
|
+
WHERE status = 'queued'
|
|
5472
|
+
AND (next_run_at IS NULL OR next_run_at <= ?)
|
|
5473
|
+
ORDER BY enqueued_at ASC
|
|
5474
|
+
LIMIT 1`,
|
|
5475
|
+
cutoff
|
|
5476
|
+
).toArray()[0];
|
|
5477
|
+
if (!row) return;
|
|
5478
|
+
await this.executeJob(row);
|
|
5479
|
+
}
|
|
5480
|
+
}
|
|
5481
|
+
async executeJob(row) {
|
|
5482
|
+
const startedAt = row.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
5483
|
+
const attemptsBefore = row.attempts;
|
|
5484
|
+
const attempt = attemptsBefore + 1;
|
|
5485
|
+
this.sql.exec(
|
|
5486
|
+
`UPDATE jobs SET status = 'running', started_at = ?, attempts = ? WHERE id = ?`,
|
|
5487
|
+
startedAt,
|
|
5488
|
+
attempt,
|
|
5489
|
+
row.id
|
|
5490
|
+
);
|
|
5491
|
+
const controller = new AbortController();
|
|
5492
|
+
this.inFlight.set(row.id, controller);
|
|
5493
|
+
this.continueState = null;
|
|
5494
|
+
const job = {
|
|
5495
|
+
...rowToJobView(row),
|
|
5496
|
+
status: "running",
|
|
5497
|
+
attempts: attempt,
|
|
5498
|
+
startedAt
|
|
5499
|
+
};
|
|
5500
|
+
let outcome = "succeeded";
|
|
5501
|
+
let result;
|
|
5502
|
+
let errorMessage;
|
|
5503
|
+
try {
|
|
5504
|
+
const ctx = this.makeContext(row.id, controller.signal);
|
|
5505
|
+
result = await this.onJob(job, ctx);
|
|
5506
|
+
if (this.continueState) outcome = "continued";
|
|
5507
|
+
} catch (e) {
|
|
5508
|
+
outcome = "failed";
|
|
5509
|
+
errorMessage = e instanceof Error ? e.message : String(e);
|
|
5510
|
+
console.error(`[JobRoom] Job "${row.type}" (${row.id}) failed:`, e);
|
|
5511
|
+
} finally {
|
|
5512
|
+
this.inFlight.delete(row.id);
|
|
5513
|
+
}
|
|
5514
|
+
const currentStatus = this.readStatus(row.id);
|
|
5515
|
+
if (currentStatus === "canceled") {
|
|
5516
|
+
this.continueState = null;
|
|
5517
|
+
return;
|
|
5518
|
+
}
|
|
5519
|
+
if (outcome === "continued") {
|
|
5520
|
+
const state = this.continueState;
|
|
5521
|
+
this.continueState = null;
|
|
5522
|
+
const nextRunAt = new Date(Date.now() + Math.max(0, state.afterMs)).toISOString();
|
|
5523
|
+
this.sql.exec(
|
|
5524
|
+
`UPDATE jobs
|
|
5525
|
+
SET status = 'queued',
|
|
5526
|
+
checkpoint = ?,
|
|
5527
|
+
next_run_at = ?
|
|
5528
|
+
WHERE id = ?`,
|
|
5529
|
+
safeStringify(state.checkpoint),
|
|
5530
|
+
nextRunAt,
|
|
5531
|
+
row.id
|
|
5532
|
+
);
|
|
5533
|
+
const updated2 = this.getJobById(row.id);
|
|
5534
|
+
if (updated2) this.broadcast(serverBuild.jobProgress(updated2));
|
|
5535
|
+
return;
|
|
5536
|
+
}
|
|
5537
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5538
|
+
if (outcome === "succeeded") {
|
|
5539
|
+
this.sql.exec(
|
|
5540
|
+
`UPDATE jobs
|
|
5541
|
+
SET status = 'succeeded',
|
|
5542
|
+
result = ?,
|
|
5543
|
+
completed_at = ?,
|
|
5544
|
+
error = NULL,
|
|
5545
|
+
checkpoint = NULL,
|
|
5546
|
+
next_run_at = NULL,
|
|
5547
|
+
progress = NULL,
|
|
5548
|
+
progress_message = NULL
|
|
5549
|
+
WHERE id = ?`,
|
|
5550
|
+
result === void 0 ? null : safeStringify(result),
|
|
5551
|
+
completedAt,
|
|
5552
|
+
row.id
|
|
5553
|
+
);
|
|
5554
|
+
const updated2 = this.getJobById(row.id);
|
|
5555
|
+
if (updated2) this.broadcast(serverBuild.jobSucceeded(updated2));
|
|
5556
|
+
return;
|
|
5557
|
+
}
|
|
5558
|
+
if (attempt < row.max_attempts) {
|
|
5559
|
+
const nextRunAt = new Date(Date.now() + this.retryBackoffMs).toISOString();
|
|
5560
|
+
this.sql.exec(
|
|
5561
|
+
`UPDATE jobs
|
|
5562
|
+
SET status = 'queued',
|
|
5563
|
+
error = ?,
|
|
5564
|
+
next_run_at = ?,
|
|
5565
|
+
started_at = NULL,
|
|
5566
|
+
progress = NULL,
|
|
5567
|
+
progress_message = NULL
|
|
5568
|
+
WHERE id = ?`,
|
|
5569
|
+
errorMessage ?? "unknown error",
|
|
5570
|
+
nextRunAt,
|
|
5571
|
+
row.id
|
|
5572
|
+
);
|
|
5573
|
+
const updated2 = this.getJobById(row.id);
|
|
5574
|
+
if (updated2) this.broadcast(serverBuild.jobRetried(updated2));
|
|
5575
|
+
return;
|
|
5576
|
+
}
|
|
5577
|
+
this.sql.exec(
|
|
5578
|
+
`UPDATE jobs
|
|
5579
|
+
SET status = 'failed',
|
|
5580
|
+
error = ?,
|
|
5581
|
+
completed_at = ?,
|
|
5582
|
+
checkpoint = NULL,
|
|
5583
|
+
next_run_at = NULL,
|
|
5584
|
+
progress = NULL,
|
|
5585
|
+
progress_message = NULL
|
|
5586
|
+
WHERE id = ?`,
|
|
5587
|
+
errorMessage ?? "unknown error",
|
|
5588
|
+
completedAt,
|
|
5589
|
+
row.id
|
|
5590
|
+
);
|
|
5591
|
+
const updated = this.getJobById(row.id);
|
|
5592
|
+
if (updated) this.broadcast(serverBuild.jobFailed(updated));
|
|
5593
|
+
}
|
|
5594
|
+
makeContext(jobId, signal) {
|
|
5595
|
+
return {
|
|
5596
|
+
progress: (value, message) => {
|
|
5597
|
+
const clamped = Math.max(0, Math.min(1, value));
|
|
5598
|
+
this.sql.exec(
|
|
5599
|
+
`UPDATE jobs SET progress = ?, progress_message = ? WHERE id = ?`,
|
|
5600
|
+
clamped,
|
|
5601
|
+
message ?? null,
|
|
5602
|
+
jobId
|
|
5603
|
+
);
|
|
5604
|
+
const updated = this.getJobById(jobId);
|
|
5605
|
+
if (updated) this.broadcast(serverBuild.jobProgress(updated));
|
|
5606
|
+
},
|
|
5607
|
+
continue: (state, options) => {
|
|
5608
|
+
this.continueState = {
|
|
5609
|
+
checkpoint: state,
|
|
5610
|
+
afterMs: Math.max(0, options?.afterMs ?? 0)
|
|
5611
|
+
};
|
|
5612
|
+
},
|
|
5613
|
+
signal
|
|
5614
|
+
};
|
|
5615
|
+
}
|
|
5616
|
+
readStatus(jobId) {
|
|
5617
|
+
const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
|
|
5618
|
+
return row?.status ?? null;
|
|
5619
|
+
}
|
|
5620
|
+
// ==========================================================================
|
|
5621
|
+
// Scheduling / recovery / pruning
|
|
5622
|
+
// ==========================================================================
|
|
5623
|
+
scheduleNextAlarm() {
|
|
5624
|
+
const row = this.sql.exec(
|
|
5625
|
+
`SELECT next_run_at FROM jobs
|
|
5626
|
+
WHERE status = 'queued'
|
|
5627
|
+
ORDER BY (next_run_at IS NULL) ASC, next_run_at ASC
|
|
5628
|
+
LIMIT 1`
|
|
5629
|
+
).toArray()[0];
|
|
5630
|
+
if (!row) return;
|
|
5631
|
+
const now = Date.now();
|
|
5632
|
+
const target = row.next_run_at ? Math.max(now, new Date(row.next_run_at).getTime()) : now;
|
|
5633
|
+
this.state.storage.setAlarm(Math.max(target, now + 50));
|
|
5634
|
+
}
|
|
5635
|
+
recoverStuckRunning() {
|
|
5636
|
+
const cutoff = new Date(Date.now() - RUNNING_STALE_MS).toISOString();
|
|
5637
|
+
const stuck = this.sql.exec(
|
|
5638
|
+
`SELECT id, attempts, max_attempts FROM jobs
|
|
5639
|
+
WHERE status = 'running' AND started_at < ?`,
|
|
5640
|
+
cutoff
|
|
5641
|
+
).toArray();
|
|
5642
|
+
for (const r of stuck) {
|
|
5643
|
+
if (r.attempts >= r.max_attempts) {
|
|
5644
|
+
this.sql.exec(
|
|
5645
|
+
`UPDATE jobs
|
|
5646
|
+
SET status = 'failed',
|
|
5647
|
+
error = 'Isolate recycled or wall time exceeded',
|
|
5648
|
+
completed_at = ?,
|
|
5649
|
+
checkpoint = NULL,
|
|
5650
|
+
next_run_at = NULL
|
|
5651
|
+
WHERE id = ?`,
|
|
5652
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
5653
|
+
r.id
|
|
5654
|
+
);
|
|
5655
|
+
} else {
|
|
5656
|
+
this.sql.exec(
|
|
5657
|
+
`UPDATE jobs
|
|
5658
|
+
SET status = 'queued',
|
|
5659
|
+
started_at = NULL,
|
|
5660
|
+
next_run_at = ?,
|
|
5661
|
+
error = 'Previous attempt killed mid-run; retrying',
|
|
5662
|
+
progress = NULL,
|
|
5663
|
+
progress_message = NULL
|
|
5664
|
+
WHERE id = ?`,
|
|
5665
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
5666
|
+
r.id
|
|
5667
|
+
);
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
5670
|
+
}
|
|
5671
|
+
pruneExpired() {
|
|
5672
|
+
if (this.retentionMs === 0) return;
|
|
5673
|
+
const cutoff = new Date(Date.now() - this.retentionMs).toISOString();
|
|
5674
|
+
this.sql.exec(
|
|
5675
|
+
`DELETE FROM jobs
|
|
5676
|
+
WHERE status IN ('succeeded', 'failed', 'canceled')
|
|
5677
|
+
AND completed_at IS NOT NULL
|
|
5678
|
+
AND completed_at < ?`,
|
|
5679
|
+
cutoff
|
|
5680
|
+
);
|
|
5681
|
+
}
|
|
5682
|
+
// ==========================================================================
|
|
5683
|
+
// Read helpers
|
|
5684
|
+
// ==========================================================================
|
|
5685
|
+
getRecentJobs(limit) {
|
|
5686
|
+
const rows = this.sql.exec(
|
|
5687
|
+
`SELECT * FROM jobs
|
|
5688
|
+
ORDER BY (status IN ('queued','running')) DESC, enqueued_at DESC
|
|
5689
|
+
LIMIT ?`,
|
|
5690
|
+
limit
|
|
5691
|
+
).toArray();
|
|
5692
|
+
return rows.map(rowToJobView);
|
|
5693
|
+
}
|
|
5694
|
+
getJobById(id) {
|
|
5695
|
+
const row = this.sql.exec(`SELECT * FROM jobs WHERE id = ?`, id).toArray()[0];
|
|
5696
|
+
return row ? rowToJobView(row) : null;
|
|
5697
|
+
}
|
|
5698
|
+
};
|
|
5699
|
+
async function enqueueJob(namespace, roomId, type, payload, options) {
|
|
5700
|
+
const stub = namespace.get(namespace.idFromName(roomId));
|
|
5701
|
+
const res = await stub.fetch(
|
|
5702
|
+
new Request("https://job-room.internal/enqueue", {
|
|
5703
|
+
method: "POST",
|
|
5704
|
+
headers: { "Content-Type": "application/json" },
|
|
5705
|
+
body: JSON.stringify({
|
|
5706
|
+
type,
|
|
5707
|
+
payload,
|
|
5708
|
+
maxAttempts: options?.maxAttempts,
|
|
5709
|
+
enqueuedBy: options?.enqueuedBy
|
|
5710
|
+
})
|
|
5711
|
+
})
|
|
5712
|
+
);
|
|
5713
|
+
if (!res.ok) {
|
|
5714
|
+
const text = await res.text().catch(() => "");
|
|
5715
|
+
throw new Error(`enqueueJob failed: ${res.status} ${text}`);
|
|
5716
|
+
}
|
|
5717
|
+
const data = await res.json();
|
|
5718
|
+
if (!data.job?.id) throw new Error("enqueueJob: malformed response");
|
|
5719
|
+
return data.job.id;
|
|
5720
|
+
}
|
|
5721
|
+
function rowToJobView(row) {
|
|
5722
|
+
return {
|
|
5723
|
+
id: row.id,
|
|
5724
|
+
type: row.type,
|
|
5725
|
+
status: row.status,
|
|
5726
|
+
payload: row.payload == null ? void 0 : JSON.parse(row.payload),
|
|
5727
|
+
result: row.result == null ? void 0 : JSON.parse(row.result),
|
|
5728
|
+
error: row.error ?? void 0,
|
|
5729
|
+
progress: row.progress ?? void 0,
|
|
5730
|
+
progressMessage: row.progress_message ?? void 0,
|
|
5731
|
+
attempts: row.attempts,
|
|
5732
|
+
maxAttempts: row.max_attempts,
|
|
5733
|
+
enqueuedAt: row.enqueued_at,
|
|
5734
|
+
startedAt: row.started_at,
|
|
5735
|
+
completedAt: row.completed_at,
|
|
5736
|
+
enqueuedBy: row.enqueued_by,
|
|
5737
|
+
resumeFrom: row.checkpoint == null ? void 0 : JSON.parse(row.checkpoint)
|
|
5738
|
+
};
|
|
5739
|
+
}
|
|
5740
|
+
function safeStringify(value) {
|
|
5741
|
+
try {
|
|
5742
|
+
return JSON.stringify(value);
|
|
5743
|
+
} catch {
|
|
5744
|
+
return JSON.stringify({ __unserializable__: String(value) });
|
|
5745
|
+
}
|
|
5746
|
+
}
|
|
5747
|
+
|
|
5170
5748
|
// src/server/rooms/do-manifest.ts
|
|
5171
5749
|
var DEFAULT_DO_MANIFEST = [
|
|
5172
5750
|
{ binding: "RECORD_ROOMS", className: "AppRecordRoom", sqlite: true },
|
|
@@ -6248,6 +6826,7 @@ export {
|
|
|
6248
6826
|
GLOBAL_DO_TYPES,
|
|
6249
6827
|
GLOBAL_DO_TYPE_NAMES,
|
|
6250
6828
|
GameRoom,
|
|
6829
|
+
JobRoom,
|
|
6251
6830
|
MESSAGES_SCHEMA,
|
|
6252
6831
|
PresenceRoom,
|
|
6253
6832
|
REACTIONS_SCHEMA,
|
|
@@ -6298,6 +6877,7 @@ export {
|
|
|
6298
6877
|
decodeJwtPayload,
|
|
6299
6878
|
deleteChatCascade,
|
|
6300
6879
|
deleteRecord,
|
|
6880
|
+
enqueueJob,
|
|
6301
6881
|
executeQuery,
|
|
6302
6882
|
getAllUsers,
|
|
6303
6883
|
getChat,
|