@lumenkey/cli 0.3.0 → 0.4.0-rc.1
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/README.md +51 -2
- package/dist/cli/copied.js +86 -85
- package/dist/cli/index.js +56 -44
- package/dist/cli/render.js +5 -1
- package/dist/core/asset-stream.d.ts +16 -0
- package/dist/core/asset-stream.js +64 -0
- package/dist/core/atomic-config-file.js +8 -7
- package/dist/core/chinese-messages.d.ts +4 -0
- package/dist/core/chinese-messages.js +907 -0
- package/dist/core/chinese-script.d.ts +7 -0
- package/dist/core/chinese-script.js +28 -0
- package/dist/core/client.js +2 -1
- package/dist/core/config-dir.js +2 -1
- package/dist/core/config.js +7 -6
- package/dist/core/download-root.d.ts +5 -0
- package/dist/core/download-root.js +64 -0
- package/dist/core/download.js +15 -12
- package/dist/core/generation-input.js +3 -2
- package/dist/core/generation.d.ts +8 -1
- package/dist/core/generation.js +13 -10
- package/dist/core/job-display.js +7 -6
- package/dist/core/locale.d.ts +2 -0
- package/dist/core/locale.js +172 -142
- package/dist/core/outcome.d.ts +6 -0
- package/dist/core/outcome.js +17 -4
- package/dist/core/output-path.d.ts +3 -1
- package/dist/core/output-path.js +5 -2
- package/dist/core/params.js +26 -25
- package/dist/core/profiles.js +5 -4
- package/dist/core/set-values.d.ts +0 -1
- package/dist/core/set-values.js +7 -7
- package/dist/core/submission-journal.js +43 -17
- package/dist/core/version.js +2 -3
- package/dist/mcp/server.js +30 -29
- package/dist/studio/asset-integrity-hash.d.ts +28 -0
- package/dist/studio/asset-integrity-hash.js +93 -0
- package/dist/studio/asset-integrity-queue.d.ts +24 -0
- package/dist/studio/asset-integrity-queue.js +94 -0
- package/dist/studio/asset-store.d.ts +52 -0
- package/dist/studio/asset-store.js +352 -0
- package/dist/studio/canvas/LICENSES.txt +1590 -0
- package/dist/studio/canvas/dependencies.json +862 -0
- package/dist/studio/canvas/director-source-manifest.json +47 -0
- package/dist/studio/canvas/editor.css +239 -0
- package/dist/studio/canvas/editor.js +4613 -0
- package/dist/studio/canvas/integration-source-manifest.json +384 -0
- package/dist/studio/canvas/source-manifest.json +240 -0
- package/dist/studio/canvas-store.d.ts +1254 -0
- package/dist/studio/canvas-store.js +205 -0
- package/dist/studio/capture-placement.d.ts +11 -0
- package/dist/studio/capture-placement.js +85 -0
- package/dist/studio/cloud-contract.d.ts +128 -0
- package/dist/studio/cloud-contract.js +229 -0
- package/dist/studio/cloud-service.d.ts +178 -0
- package/dist/studio/cloud-service.js +202 -0
- package/dist/studio/director-captures.d.ts +59 -0
- package/dist/studio/director-captures.js +141 -0
- package/dist/studio/director-contract.d.ts +1489 -0
- package/dist/studio/director-contract.js +288 -0
- package/dist/studio/director-png.d.ts +6 -0
- package/dist/studio/director-png.js +41 -0
- package/dist/studio/director-relations.d.ts +40 -0
- package/dist/studio/director-relations.js +103 -0
- package/dist/studio/document-authority.d.ts +7 -0
- package/dist/studio/document-authority.js +50 -0
- package/dist/studio/draft-store.d.ts +95 -0
- package/dist/studio/draft-store.js +100 -0
- package/dist/studio/errors.d.ts +85 -0
- package/dist/studio/errors.js +94 -0
- package/dist/studio/launch.d.ts +5 -0
- package/dist/studio/launch.js +31 -0
- package/dist/studio/media.d.ts +9 -0
- package/dist/studio/media.js +50 -0
- package/dist/studio/output-save-contract.d.ts +1321 -0
- package/dist/studio/output-save-contract.js +26 -0
- package/dist/studio/output-save-journal.d.ts +971 -0
- package/dist/studio/output-save-journal.js +287 -0
- package/dist/studio/output-save-recovery.d.ts +18 -0
- package/dist/studio/output-save-recovery.js +161 -0
- package/dist/studio/output-save-service.d.ts +436 -0
- package/dist/studio/output-save-service.js +251 -0
- package/dist/studio/output-save-worker.d.ts +40 -0
- package/dist/studio/output-save-worker.js +360 -0
- package/dist/studio/paths.d.ts +6 -0
- package/dist/studio/paths.js +68 -0
- package/dist/studio/run-journal.d.ts +993 -0
- package/dist/studio/run-journal.js +125 -0
- package/dist/studio/server.d.ts +17 -0
- package/dist/studio/server.js +440 -0
- package/dist/studio/session.d.ts +6 -0
- package/dist/studio/session.js +26 -0
- package/dist/studio/shell.d.ts +3 -0
- package/dist/studio/shell.js +23 -0
- package/dist/studio/style.d.ts +2 -0
- package/dist/studio/style.js +29 -0
- package/dist/studio/types.d.ts +26 -0
- package/dist/studio/types.js +2 -0
- package/dist/studio/web/app.d.ts +2 -0
- package/dist/studio/web/app.js +662 -0
- package/dist/studio/web/asset-controller.d.ts +37 -0
- package/dist/studio/web/asset-controller.js +191 -0
- package/dist/studio/web/asset-model.d.ts +29 -0
- package/dist/studio/web/asset-model.js +51 -0
- package/dist/studio/web/controller.d.ts +113 -0
- package/dist/studio/web/controller.js +401 -0
- package/dist/studio/web/messages.d.ts +2 -0
- package/dist/studio/web/messages.js +74 -0
- package/dist/studio/web/model.d.ts +75 -0
- package/dist/studio/web/model.js +182 -0
- package/dist/studio/web/save-controller.d.ts +85 -0
- package/dist/studio/web/save-controller.js +495 -0
- package/dist/studio/web/save-model.d.ts +63 -0
- package/dist/studio/web/save-model.js +64 -0
- package/dist/studio/web/save-queue.d.ts +7 -0
- package/dist/studio/web/save-queue.js +62 -0
- package/dist/studio/web/save-view.d.ts +9 -0
- package/dist/studio/web/save-view.js +19 -0
- package/dist/studio/writer-lock.d.ts +5 -0
- package/dist/studio/writer-lock.js +88 -0
- package/package.json +11 -5
- package/dist/cli/copied.d.ts.map +0 -1
- package/dist/cli/copied.js.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/render.d.ts.map +0 -1
- package/dist/cli/render.js.map +0 -1
- package/dist/core/atomic-config-file.d.ts.map +0 -1
- package/dist/core/atomic-config-file.js.map +0 -1
- package/dist/core/chat.d.ts.map +0 -1
- package/dist/core/chat.js.map +0 -1
- package/dist/core/client.d.ts.map +0 -1
- package/dist/core/client.js.map +0 -1
- package/dist/core/config-dir.d.ts.map +0 -1
- package/dist/core/config-dir.js.map +0 -1
- package/dist/core/config-error.d.ts.map +0 -1
- package/dist/core/config-error.js.map +0 -1
- package/dist/core/config.d.ts.map +0 -1
- package/dist/core/config.js.map +0 -1
- package/dist/core/credit-cap.d.ts.map +0 -1
- package/dist/core/credit-cap.js.map +0 -1
- package/dist/core/download.d.ts.map +0 -1
- package/dist/core/download.js.map +0 -1
- package/dist/core/error-result.d.ts.map +0 -1
- package/dist/core/error-result.js.map +0 -1
- package/dist/core/explain.d.ts.map +0 -1
- package/dist/core/explain.js.map +0 -1
- package/dist/core/generation-input.d.ts.map +0 -1
- package/dist/core/generation-input.js.map +0 -1
- package/dist/core/generation-result.d.ts.map +0 -1
- package/dist/core/generation-result.js.map +0 -1
- package/dist/core/generation.d.ts.map +0 -1
- package/dist/core/generation.js.map +0 -1
- package/dist/core/job-display.d.ts.map +0 -1
- package/dist/core/job-display.js.map +0 -1
- package/dist/core/locale.d.ts.map +0 -1
- package/dist/core/locale.js.map +0 -1
- package/dist/core/origin.d.ts.map +0 -1
- package/dist/core/origin.js.map +0 -1
- package/dist/core/outcome.d.ts.map +0 -1
- package/dist/core/outcome.js.map +0 -1
- package/dist/core/output-path.d.ts.map +0 -1
- package/dist/core/output-path.js.map +0 -1
- package/dist/core/params.d.ts.map +0 -1
- package/dist/core/params.js.map +0 -1
- package/dist/core/poller.d.ts.map +0 -1
- package/dist/core/poller.js.map +0 -1
- package/dist/core/profiles.d.ts.map +0 -1
- package/dist/core/profiles.js.map +0 -1
- package/dist/core/set-values.d.ts.map +0 -1
- package/dist/core/set-values.js.map +0 -1
- package/dist/core/submission-journal.d.ts.map +0 -1
- package/dist/core/submission-journal.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/version.d.ts.map +0 -1
- package/dist/core/version.js.map +0 -1
- package/dist/core/wait.d.ts.map +0 -1
- package/dist/core/wait.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcp/render.d.ts.map +0 -1
- package/dist/mcp/render.js.map +0 -1
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/mcp/server.js.map +0 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, open, readdir, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { assertRegularFile, assertStudioRoot } from './paths.js';
|
|
6
|
+
import { hasFsCode, StudioError } from './errors.js';
|
|
7
|
+
import { prepareDocumentAuthority } from './document-authority.js';
|
|
8
|
+
const integer = z.number().int().nonnegative().safe();
|
|
9
|
+
export const uuid = z.string().uuid();
|
|
10
|
+
export const errorCode = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,79}$/);
|
|
11
|
+
export const isoDate = z.string().datetime({ offset: true });
|
|
12
|
+
const diagnosticSchema = z.object({ code: errorCode, message: z.string().max(1000), type: z.string().max(80), httpStatus: z.number().int().min(400).max(599).optional(), submission: z.enum(['none', 'unknown', 'created']).optional(), recovery: z.enum(['rerun', 'resume-command', 'expired', 'ledger']).optional(), resumeDeadline: isoDate.optional() }).strict();
|
|
13
|
+
export function isSafeJson(value, depth = 0) {
|
|
14
|
+
if (depth > 20)
|
|
15
|
+
return false;
|
|
16
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
17
|
+
return true;
|
|
18
|
+
if (typeof value === 'number')
|
|
19
|
+
return Number.isFinite(value);
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return value.every(v => isSafeJson(v, depth + 1));
|
|
22
|
+
return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype &&
|
|
23
|
+
Object.entries(value).every(([k, v]) => !['__proto__', 'constructor', 'prototype'].includes(k) && isSafeJson(v, depth + 1));
|
|
24
|
+
}
|
|
25
|
+
export const inputSchema = z.unknown().refine(v => isSafeJson(v) && !!v && typeof v === 'object' && !Array.isArray(v) && Buffer.byteLength(JSON.stringify(v)) <= 128 * 1024).pipe(z.record(z.unknown()));
|
|
26
|
+
export const quoteSchema = z.object({ quoteId: uuid, modelId: z.string().min(1).max(200), credits: integer, expiresAt: isoDate }).strict();
|
|
27
|
+
export const jobSchema = z.object({
|
|
28
|
+
jobId: uuid, status: z.enum(['accepted', 'policy_checking', 'queued', 'submitting', 'provider_running', 'output_checking', 'succeeded', 'rejected', 'failed', 'cancelled', 'expired']),
|
|
29
|
+
outputs: z.array(z.object({ url: z.string().max(8192), type: z.string().max(40).optional(), assetId: uuid.optional(), outputIndex: integer.optional() }).strict().refine(o => (o.assetId === undefined) === (o.outputIndex === undefined))).max(100),
|
|
30
|
+
identityReason: z.enum(['LEGACY_AWAITING_FRESH_GET', 'JOB_UNAVAILABLE', 'NO_VERIFIABLE_OUTPUTS', 'IDENTITY_CONFLICT']).optional(),
|
|
31
|
+
identityEvidence: z.array(z.object({ assetId: uuid, type: z.enum(['image', 'video', 'audio']) }).strict()).max(1000).refine(v => new Set(v.map(o => o.assetId.toLowerCase())).size === v.length).optional(),
|
|
32
|
+
error: z.object({ code: errorCode, message: z.string().max(200) }).strict().optional(),
|
|
33
|
+
}).strict();
|
|
34
|
+
export const runSchema = z.object({
|
|
35
|
+
runId: uuid, binding: z.string().regex(/^[a-f0-9]{64}$/), modelId: z.string().min(1).max(200), input: inputSchema,
|
|
36
|
+
projectId: uuid, idempotencyKey: uuid, quote: quoteSchema, requestedInput: inputSchema.optional(),
|
|
37
|
+
state: z.enum(['QUOTED', 'SUBMITTING', 'SUBMISSION_UNKNOWN', 'SUBMISSION_REJECTED', 'ACCEPTED', 'TERMINAL']),
|
|
38
|
+
job: jobSchema.optional(), lastErrorCode: errorCode.optional(), lastFailure: diagnosticSchema.optional(), createdAt: isoDate, updatedAt: isoDate,
|
|
39
|
+
}).strict().refine(v => v.quote.modelId === v.modelId && (!['ACCEPTED', 'TERMINAL'].includes(v.state) || !!v.job));
|
|
40
|
+
const journalSchema = z.object({ schemaVersion: z.literal(1), revision: integer, runs: z.array(runSchema).max(1000) }).strict()
|
|
41
|
+
.refine(v => new Set(v.runs.map(r => r.runId)).size === v.runs.length && new Set(v.runs.map(r => r.idempotencyKey)).size === v.runs.length);
|
|
42
|
+
function validate(value) {
|
|
43
|
+
const parsed = journalSchema.safeParse(value);
|
|
44
|
+
if (!parsed.success || Buffer.byteLength(JSON.stringify(parsed.data)) > 8 * 1024 * 1024)
|
|
45
|
+
throw new StudioError('STUDIO_JOURNAL_INVALID', 409);
|
|
46
|
+
return parsed.data;
|
|
47
|
+
}
|
|
48
|
+
/** The owning asset store supplies its exclusive writer and lifecycle queue. */
|
|
49
|
+
export function createRunJournal(root, serial) {
|
|
50
|
+
const file = path.join(root.managedDir, 'runs.json');
|
|
51
|
+
let current;
|
|
52
|
+
async function disk() {
|
|
53
|
+
await assertStudioRoot(root);
|
|
54
|
+
try {
|
|
55
|
+
await assertRegularFile(file);
|
|
56
|
+
const handle = await open(file, 'r');
|
|
57
|
+
try {
|
|
58
|
+
if ((await handle.stat()).size > 8 * 1024 * 1024)
|
|
59
|
+
throw new Error();
|
|
60
|
+
return validate(JSON.parse(await handle.readFile('utf8')));
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (hasFsCode(e, 'ENOENT'))
|
|
68
|
+
return undefined;
|
|
69
|
+
throw new StudioError('STUDIO_JOURNAL_INVALID', 409);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function commit(next, initial = false) {
|
|
73
|
+
await assertStudioRoot(root);
|
|
74
|
+
const temp = path.join(root.managedDir, `runs.${randomUUID()}.tmp`);
|
|
75
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
76
|
+
try {
|
|
77
|
+
await handle.writeFile(JSON.stringify(next));
|
|
78
|
+
await handle.sync();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await handle.close();
|
|
82
|
+
}
|
|
83
|
+
await assertStudioRoot(root);
|
|
84
|
+
if (initial) {
|
|
85
|
+
await link(temp, file);
|
|
86
|
+
await unlink(temp);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
await assertRegularFile(file);
|
|
90
|
+
await rename(temp, file);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function authority() {
|
|
94
|
+
const loaded = await disk();
|
|
95
|
+
if (loaded)
|
|
96
|
+
await prepareDocumentAuthority(root, 'runs', true, 'STUDIO_JOURNAL_INVALID');
|
|
97
|
+
if (current) {
|
|
98
|
+
if (!loaded || JSON.stringify(loaded) !== JSON.stringify(current))
|
|
99
|
+
throw new StudioError('STUDIO_JOURNAL_INVALID', 409);
|
|
100
|
+
}
|
|
101
|
+
else if (loaded)
|
|
102
|
+
current = loaded;
|
|
103
|
+
else {
|
|
104
|
+
if ((await readdir(root.managedDir)).some(n => /^runs\..+\.tmp$/.test(n)))
|
|
105
|
+
throw new StudioError('STUDIO_JOURNAL_INVALID', 409);
|
|
106
|
+
await prepareDocumentAuthority(root, 'runs', false, 'STUDIO_JOURNAL_INVALID');
|
|
107
|
+
const empty = { schemaVersion: 1, revision: 0, runs: [] };
|
|
108
|
+
await commit(empty, true);
|
|
109
|
+
current = empty;
|
|
110
|
+
}
|
|
111
|
+
return current;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
read: () => serial(async () => structuredClone(await authority())),
|
|
115
|
+
write: next => serial(async () => {
|
|
116
|
+
const validated = validate(next);
|
|
117
|
+
const before = await authority();
|
|
118
|
+
if (validated.revision !== before.revision + 1)
|
|
119
|
+
throw new StudioError('STUDIO_JOURNAL_INVALID', 409);
|
|
120
|
+
await commit(validated);
|
|
121
|
+
current = validated;
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=run-journal.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { openStudioAssetStore } from './asset-store.js';
|
|
2
|
+
import { createOutputSaveWorker } from './output-save-worker.js';
|
|
3
|
+
import type { LumenKeyConfig } from '../core/types.js';
|
|
4
|
+
export interface StudioServerHandle {
|
|
5
|
+
origin: string;
|
|
6
|
+
launchUrl: string;
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare function startStudioServer(options?: {
|
|
10
|
+
outputDir?: string;
|
|
11
|
+
maxBytes?: number;
|
|
12
|
+
integrity?: Parameters<typeof openStudioAssetStore>[0]['integrity'];
|
|
13
|
+
loadCloudConfig?: () => LumenKeyConfig | Promise<LumenKeyConfig>;
|
|
14
|
+
storageFaults?: Parameters<typeof openStudioAssetStore>[0]['faults'];
|
|
15
|
+
saveWorker?: Pick<Parameters<typeof createOutputSaveWorker>[0], 'fetchAsset' | 'hooks'>;
|
|
16
|
+
}): Promise<StudioServerHandle>;
|
|
17
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
5
|
+
import { openStudioAssetStore } from './asset-store.js';
|
|
6
|
+
import { storageError, StudioError } from './errors.js';
|
|
7
|
+
import { createStudioSession } from './session.js';
|
|
8
|
+
import { studioHtml, studioScript } from './shell.js';
|
|
9
|
+
import { studioStyle } from './style.js';
|
|
10
|
+
import { createStudioCloudService } from './cloud-service.js';
|
|
11
|
+
import { cloudHttpError } from './cloud-contract.js';
|
|
12
|
+
import { createOutputSaveService, saveHttpError } from './output-save-service.js';
|
|
13
|
+
import { createOutputSaveWorker } from './output-save-worker.js';
|
|
14
|
+
import { StudioCaptureError } from './director-captures.js';
|
|
15
|
+
function json(response, status, value) {
|
|
16
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
17
|
+
response.end(JSON.stringify(value));
|
|
18
|
+
}
|
|
19
|
+
function waitForDrain(response) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const cleanup = () => { response.off('drain', drained); response.off('close', failed); response.off('error', failed); };
|
|
22
|
+
const drained = () => { cleanup(); resolve(); };
|
|
23
|
+
const failed = () => { cleanup(); reject(new StudioError('STUDIO_STORAGE_FAILED', 500)); };
|
|
24
|
+
if (response.destroyed) {
|
|
25
|
+
failed();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
response.once('drain', drained);
|
|
29
|
+
response.once('close', failed);
|
|
30
|
+
response.once('error', failed);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function smallJson(request, limit = 1024) {
|
|
34
|
+
if (request.headers['content-type'] !== 'application/json')
|
|
35
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
36
|
+
if (request.headers['content-encoding'])
|
|
37
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
38
|
+
const declaredLength = request.headers['content-length'];
|
|
39
|
+
if (declaredLength !== undefined && Number(declaredLength) > limit)
|
|
40
|
+
throw new StudioError('STUDIO_INPUT_INVALID', 413);
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let size = 0;
|
|
43
|
+
// Keep the response writable when rejecting a chunked oversized request.
|
|
44
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
45
|
+
size += chunk.length;
|
|
46
|
+
if (size > limit)
|
|
47
|
+
throw new StudioError('STUDIO_INPUT_INVALID', 413);
|
|
48
|
+
chunks.push(chunk);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function byteRange(header, size) {
|
|
58
|
+
if (!header)
|
|
59
|
+
return { start: 0, end: size - 1, partial: false };
|
|
60
|
+
const match = header.length < 100 && /^bytes=(\d*)-(\d*)$/.exec(header);
|
|
61
|
+
if (!match || (!match[1] && !match[2]))
|
|
62
|
+
throw new StudioError('STUDIO_RANGE_INVALID', 416);
|
|
63
|
+
const start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
|
|
64
|
+
const end = match[1] && match[2] ? Math.min(size - 1, Number(match[2])) : size - 1;
|
|
65
|
+
if (![start, end].every(Number.isSafeInteger) || start < 0 || start >= size || end < start || (!match[1] && Number(match[2]) === 0))
|
|
66
|
+
throw new StudioError('STUDIO_RANGE_INVALID', 416);
|
|
67
|
+
return { start, end, partial: true };
|
|
68
|
+
}
|
|
69
|
+
export async function startStudioServer(options = {}) {
|
|
70
|
+
const browserModules = new Set((await readdir(new URL('./web/', import.meta.url), { withFileTypes: true })).filter(e => e.isFile() && /^[a-z][a-z0-9-]{0,40}\.js$/.test(e.name)).map(e => `/studio/web/${e.name}`));
|
|
71
|
+
const store = options.outputDir === undefined ? undefined : await openStudioAssetStore({ outputDir: options.outputDir, maxBytes: options.maxBytes, faults: options.storageFaults, integrity: options.integrity });
|
|
72
|
+
const loadConfig = options.loadCloudConfig ?? (async () => (await import('../core/config.js')).loadConfig());
|
|
73
|
+
let saveWorker;
|
|
74
|
+
let closing = false;
|
|
75
|
+
const cloud = createStudioCloudService({ store, loadConfig });
|
|
76
|
+
const saves = createOutputSaveService({ store, outputDir: options.outputDir, loadConfig, ...(store && options.outputDir ? { makeWorker: () => saveWorker ??= createOutputSaveWorker({ ...options.saveWorker, store, outputDir: options.outputDir, loadConfig }) } : {}) });
|
|
77
|
+
const session = createStudioSession();
|
|
78
|
+
const styleNonce = randomBytes(24).toString('base64');
|
|
79
|
+
let origin = '';
|
|
80
|
+
const active = new Set();
|
|
81
|
+
const server = createServer({ maxHeaderSize: 8192, requestTimeout: 30_000, headersTimeout: 10_000 }, (request, response) => {
|
|
82
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
83
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
84
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
85
|
+
response.setHeader('Content-Security-Policy', `default-src 'none'; script-src 'self'; style-src 'self'; style-src-elem 'self' 'nonce-${styleNonce}'; style-src-attr 'unsafe-inline'; connect-src 'self'; img-src 'self'; media-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`);
|
|
86
|
+
const action = handle(request, response).catch(error => {
|
|
87
|
+
if (response.destroyed)
|
|
88
|
+
return;
|
|
89
|
+
if (response.headersSent) {
|
|
90
|
+
response.destroy();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Drain rejected input without retaining it. Closing mid-upload can reset
|
|
94
|
+
// the socket before clients receive the structured 413/400 response.
|
|
95
|
+
request.resume();
|
|
96
|
+
const requestPath = (request.url ?? '').split('?')[0];
|
|
97
|
+
if (requestPath.startsWith('/studio/api/saves/') || /^\/studio\/api\/runs\/[^/]+\/outputs(?:\/[^/]+\/save)?$/.test(requestPath) || /^\/studio\/api\/assets\/[^/]+$/.test(requestPath)) {
|
|
98
|
+
const safe = saveHttpError(error, requestPath.endsWith('/saves/bind'));
|
|
99
|
+
json(response, safe.status, safe.body);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const safe = storageError(error);
|
|
103
|
+
json(response, safe.status, { error: { code: safe.code, message: safe.message }, ...(error instanceof StudioCaptureError ? { capture: error.capture } : {}) });
|
|
104
|
+
}).finally(() => active.delete(action));
|
|
105
|
+
active.add(action);
|
|
106
|
+
});
|
|
107
|
+
server.maxRequestsPerSocket = 100;
|
|
108
|
+
// Retire idle keep-alive sockets before header (10s) / request (30s) deadlines.
|
|
109
|
+
server.keepAliveTimeout = 2_000;
|
|
110
|
+
server.setTimeout(30_000, socket => socket.destroy());
|
|
111
|
+
server.on('clientError', (error, socket) => {
|
|
112
|
+
const code = error.code ?? '';
|
|
113
|
+
if (socket.bytesRead === 0 || /TIMEOUT|TIMEDOUT|ECONNRESET|EPIPE/.test(code) || socket.destroyed) {
|
|
114
|
+
socket.destroy();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
118
|
+
});
|
|
119
|
+
async function handle(request, response) {
|
|
120
|
+
if (closing)
|
|
121
|
+
throw new StudioError('STUDIO_STORAGE_CLOSED', 503);
|
|
122
|
+
const hostname = origin.slice('http://'.length);
|
|
123
|
+
const hostCount = request.rawHeaders.filter((_, i) => i % 2 === 0 && request.rawHeaders[i].toLowerCase() === 'host').length;
|
|
124
|
+
if (hostCount !== 1 || request.headers.host !== hostname || (request.headers.origin !== undefined && request.headers.origin !== origin))
|
|
125
|
+
throw new StudioError('STUDIO_REQUEST_FORBIDDEN', 403);
|
|
126
|
+
// Check the raw target before URL normalization can erase traversal segments.
|
|
127
|
+
if ((request.url ?? '').startsWith('/studio/web/')) {
|
|
128
|
+
const target = request.url;
|
|
129
|
+
if (request.method !== 'GET' || !/^\/studio\/web\/[a-z][a-z0-9-]{0,40}\.js$/.test(target) || !browserModules.has(target))
|
|
130
|
+
throw new StudioError('STUDIO_ROUTE_NOT_FOUND', 404);
|
|
131
|
+
const source = await readFile(new URL('./web/' + target.slice('/studio/web/'.length), import.meta.url), 'utf8');
|
|
132
|
+
response.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
|
|
133
|
+
response.end(source);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const url = new URL(request.url ?? '/', origin);
|
|
137
|
+
const assetMetadata = /^\/studio\/api\/assets\/([^/]+)$/.exec(url.pathname);
|
|
138
|
+
const captureRoute = /^\/studio\/api\/canvases\/([^/]+)\/director-captures(?:\/([^/]+)(\/retry)?)?$/.exec(url.pathname);
|
|
139
|
+
const captureList = request.method === 'GET' && captureRoute && !captureRoute[2];
|
|
140
|
+
if (url.origin !== origin || (url.search && !captureList && !assetMetadata) || url.hash)
|
|
141
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
142
|
+
if (request.method === 'GET' && ['/studio/', '/studio/app.js'].includes(url.pathname)) {
|
|
143
|
+
response.writeHead(200, { 'Content-Type': url.pathname.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/html; charset=utf-8' });
|
|
144
|
+
response.end(url.pathname.endsWith('.js') ? studioScript : studioHtml.replace('__STUDIO_STYLE_NONCE__', styleNonce));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (request.method === 'GET' && url.pathname === '/studio/style.css') {
|
|
148
|
+
response.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
|
|
149
|
+
response.end(studioStyle);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (request.method === 'GET' && ['/studio/canvas/editor.js', '/studio/canvas/editor.css'].includes(url.pathname)) {
|
|
153
|
+
const name = url.pathname.endsWith('.css') ? 'editor.css' : 'editor.js';
|
|
154
|
+
const source = await readFile(new URL('./canvas/' + name, import.meta.url));
|
|
155
|
+
response.writeHead(200, { 'Content-Type': name.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8' });
|
|
156
|
+
response.end(source);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (request.method !== 'GET' && request.headers.origin !== origin)
|
|
160
|
+
throw new StudioError('STUDIO_REQUEST_FORBIDDEN', 403);
|
|
161
|
+
if (request.method === 'POST' && url.pathname === '/studio/api/session/bootstrap') {
|
|
162
|
+
saves.invalidate();
|
|
163
|
+
const value = await smallJson(request);
|
|
164
|
+
if (!value || typeof value !== 'object' || !('launch' in value) || Object.keys(value).length !== 1)
|
|
165
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
166
|
+
response.setHeader('Set-Cookie', session.bootstrap(value.launch));
|
|
167
|
+
json(response, 200, { accepted: true });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
session.assert(request.headers.cookie);
|
|
171
|
+
if (request.method === 'GET' && assetMetadata) {
|
|
172
|
+
if (!store)
|
|
173
|
+
throw new StudioError('STUDIO_OUTPUT_ROOT_REQUIRED', 409);
|
|
174
|
+
if (url.search || url.hash || request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0'))
|
|
175
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
176
|
+
const asset = await store.metadata(assetMetadata[1]);
|
|
177
|
+
json(response, 200, { scope: saves.localScope(), asset });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const outputList = /^\/studio\/api\/runs\/([^/]+)\/outputs$/.exec(url.pathname);
|
|
181
|
+
const outputSave = /^\/studio\/api\/runs\/([^/]+)\/outputs\/([^/]+)\/save$/.exec(url.pathname);
|
|
182
|
+
const saveOperation = /^\/studio\/api\/saves\/([^/]+)(\/retry)?$/.exec(url.pathname);
|
|
183
|
+
if (url.pathname === '/studio/api/saves/bind' || outputList || outputSave || saveOperation) {
|
|
184
|
+
if (request.method === 'POST')
|
|
185
|
+
await saves.preflight(url.pathname === '/studio/api/saves/bind');
|
|
186
|
+
const authorizedBody = async () => {
|
|
187
|
+
let body;
|
|
188
|
+
try {
|
|
189
|
+
body = await smallJson(request);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// Reading the body yields: a changed binding/config takes precedence over parsing.
|
|
193
|
+
await saves.preflight(url.pathname === '/studio/api/saves/bind');
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
await saves.preflight(url.pathname === '/studio/api/saves/bind');
|
|
197
|
+
return body;
|
|
198
|
+
};
|
|
199
|
+
if (request.method === 'GET' && (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0')))
|
|
200
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
201
|
+
if (request.method === 'POST' && url.pathname === '/studio/api/saves/bind') {
|
|
202
|
+
const body = await authorizedBody();
|
|
203
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length)
|
|
204
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
205
|
+
json(response, 200, await saves.bind());
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (request.method === 'GET' && outputList) {
|
|
209
|
+
if (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0'))
|
|
210
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
211
|
+
json(response, 200, await saves.outputs(outputList[1]));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (request.method === 'GET' && outputSave) {
|
|
215
|
+
json(response, 200, await saves.output(outputSave[1], outputSave[2]));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (request.method === 'GET' && saveOperation && !saveOperation[2]) {
|
|
219
|
+
json(response, 200, await saves.read(saveOperation[1]));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (request.method === 'POST' && (outputSave || saveOperation?.[2])) {
|
|
223
|
+
const body = await authorizedBody();
|
|
224
|
+
const { status, ...result } = outputSave ? await saves.accept(outputSave[1], outputSave[2], body) : await saves.retry(saveOperation[1], body);
|
|
225
|
+
json(response, status, result);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
throw new StudioError('STUDIO_ROUTE_NOT_FOUND', 404);
|
|
229
|
+
}
|
|
230
|
+
if (request.method === 'GET' && url.pathname === '/studio/api/session') {
|
|
231
|
+
json(response, 200, { apiVersion: 1, storage: { configured: !!store, ...(store ? { outputDir: options.outputDir } : {}) }, capabilities: { localAssets: !!store, cloudGeneration: !!store }, cloud: cloud.status() });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (url.pathname.startsWith('/studio/api/cloud/') || url.pathname === '/studio/api/quotes' || url.pathname === '/studio/api/runs' || url.pathname.startsWith('/studio/api/runs/')) {
|
|
235
|
+
try {
|
|
236
|
+
const route = url.pathname.slice('/studio/api/'.length);
|
|
237
|
+
if (request.method === 'GET') {
|
|
238
|
+
if (route === 'cloud/models') {
|
|
239
|
+
json(response, 200, { items: await cloud.models() });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (route === 'cloud/balance') {
|
|
243
|
+
json(response, 200, await cloud.balance());
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (route === 'runs') {
|
|
247
|
+
json(response, 200, { items: await cloud.list() });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (request.method === 'POST') {
|
|
252
|
+
const body = await smallJson(request, route === 'quotes' ? 192 * 1024 : 1024);
|
|
253
|
+
if (route === 'quotes') {
|
|
254
|
+
json(response, 201, await cloud.quote(body));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
258
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
259
|
+
const match = /^runs\/([^/]+)\/(confirm|refresh)$/.exec(route);
|
|
260
|
+
if (match?.[2] === 'confirm') {
|
|
261
|
+
if (Object.keys(body).length !== 1 || !('confirm' in body))
|
|
262
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
263
|
+
json(response, 200, await cloud.confirm(match[1], body.confirm));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (Object.keys(body).length)
|
|
267
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
268
|
+
if (route === 'cloud/connect') {
|
|
269
|
+
saves.invalidate();
|
|
270
|
+
json(response, 200, await cloud.connect());
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (route === 'cloud/disconnect') {
|
|
274
|
+
saves.invalidate();
|
|
275
|
+
json(response, 200, await cloud.disconnect());
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (match?.[2] === 'refresh') {
|
|
279
|
+
json(response, 200, await cloud.refresh(match[1]));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
throw new StudioError('STUDIO_ROUTE_NOT_FOUND', 404);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
const safe = cloudHttpError(error);
|
|
287
|
+
json(response, safe.status, safe.body);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (captureRoute) {
|
|
292
|
+
if (!store)
|
|
293
|
+
throw new StudioError('STUDIO_OUTPUT_ROOT_REQUIRED', 409);
|
|
294
|
+
const [, canvasId, captureId, retry] = captureRoute;
|
|
295
|
+
if (request.method === 'GET') {
|
|
296
|
+
if (retry)
|
|
297
|
+
throw new StudioError('STUDIO_ROUTE_NOT_FOUND', 404);
|
|
298
|
+
if (captureId) {
|
|
299
|
+
json(response, 200, await store.directorCaptures.read(canvasId, captureId));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const query = url.searchParams, keys = [...query.keys()];
|
|
303
|
+
if (keys.some(k => !['limit', 'cursor'].includes(k)) || new Set(keys).size !== keys.length)
|
|
304
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
305
|
+
const limit = query.get('limit');
|
|
306
|
+
if (limit !== null && !/^(?:[1-9]\d?|100)$/.test(limit))
|
|
307
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
308
|
+
json(response, 200, await store.directorCaptures.list(canvasId, { ...(limit === null ? {} : { limit: Number(limit) }), ...(query.has('cursor') ? { cursor: query.get('cursor') } : {}) }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (request.method === 'POST') {
|
|
312
|
+
const body = await smallJson(request, captureId ? 1024 : 12 * 1024 * 1024);
|
|
313
|
+
if (captureId && !retry)
|
|
314
|
+
throw new StudioError('STUDIO_ROUTE_NOT_FOUND', 404);
|
|
315
|
+
if (retry && (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length !== 0))
|
|
316
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
317
|
+
const { status, ...receipt } = retry ? await store.directorCaptures.retry(canvasId, captureId) : await store.directorCaptures.create(canvasId, body);
|
|
318
|
+
json(response, status, receipt);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
throw new StudioError('STUDIO_ROUTE_NOT_FOUND', 404);
|
|
322
|
+
}
|
|
323
|
+
const canvasRoute = /^\/studio\/api\/canvases(?:\/([^/]+))?$/.exec(url.pathname);
|
|
324
|
+
if (canvasRoute) {
|
|
325
|
+
if (!store)
|
|
326
|
+
throw new StudioError('STUDIO_OUTPUT_ROOT_REQUIRED', 409);
|
|
327
|
+
const id = canvasRoute[1];
|
|
328
|
+
if (request.method === 'GET') {
|
|
329
|
+
json(response, 200, id ? await store.canvases.read(id) : await store.canvases.list());
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (request.method === 'POST') {
|
|
333
|
+
const body = await smallJson(request, id ? 544 * 1024 : 1024);
|
|
334
|
+
json(response, id ? 200 : 201, id ? await store.canvases.save(id, body) : await store.canvases.create(body));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (url.pathname === '/studio/api/drafts') {
|
|
339
|
+
if (!store)
|
|
340
|
+
throw new StudioError('STUDIO_OUTPUT_ROOT_REQUIRED', 409);
|
|
341
|
+
if (request.method === 'GET') {
|
|
342
|
+
json(response, 200, await store.drafts.read());
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (request.method === 'POST') {
|
|
346
|
+
json(response, 200, await store.drafts.save(await smallJson(request, 192 * 1024)));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (url.pathname === '/studio/api/assets') {
|
|
351
|
+
if (request.method === 'GET') {
|
|
352
|
+
json(response, 200, store ? await store.list() : { revision: 0, assets: [], recoveryIssues: [] });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (request.method === 'POST') {
|
|
356
|
+
if (!store)
|
|
357
|
+
throw new StudioError('STUDIO_OUTPUT_ROOT_REQUIRED', 409);
|
|
358
|
+
const header = request.headers['x-studio-display-name'];
|
|
359
|
+
let displayName;
|
|
360
|
+
try {
|
|
361
|
+
if (typeof header !== 'string' || header.length > 2400)
|
|
362
|
+
throw new Error();
|
|
363
|
+
displayName = decodeURIComponent(header);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
367
|
+
}
|
|
368
|
+
if (request.headers['content-encoding'])
|
|
369
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
370
|
+
const length = request.headers['content-length'];
|
|
371
|
+
const asset = await store.save({ displayName, contentType: request.headers['content-type'] ?? '', ...(length === undefined ? {} : { expectedByteLength: Number(length) }), stream: request });
|
|
372
|
+
json(response, 201, asset);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const content = /^\/studio\/api\/assets\/([^/]+)\/content$/.exec(url.pathname);
|
|
377
|
+
if (request.method === 'GET' && content) {
|
|
378
|
+
if (!store)
|
|
379
|
+
throw new StudioError('STUDIO_OUTPUT_ROOT_REQUIRED', 409);
|
|
380
|
+
const { asset, stream } = await store.read(content[1]);
|
|
381
|
+
const abort = () => stream.destroy();
|
|
382
|
+
response.on('close', abort);
|
|
383
|
+
try {
|
|
384
|
+
const range = byteRange(request.headers.range, asset.byteLength);
|
|
385
|
+
response.writeHead(range.partial ? 206 : 200, { 'Content-Type': asset.mediaType, 'Content-Length': range.end - range.start + 1, 'Accept-Ranges': 'bytes', ...(range.partial ? { 'Content-Range': `bytes ${range.start}-${range.end}/${asset.byteLength}` } : {}) });
|
|
386
|
+
let offset = 0;
|
|
387
|
+
for await (const chunk of stream) {
|
|
388
|
+
const bytes = Buffer.from(chunk);
|
|
389
|
+
const start = Math.max(0, range.start - offset);
|
|
390
|
+
const end = Math.min(bytes.length, range.end + 1 - offset);
|
|
391
|
+
offset += bytes.length;
|
|
392
|
+
if (end > start && !response.write(bytes.subarray(start, end)))
|
|
393
|
+
await waitForDrain(response);
|
|
394
|
+
if (offset > range.end)
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
response.end();
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
response.off('close', abort);
|
|
401
|
+
stream.destroy();
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
throw new StudioError('STUDIO_ROUTE_NOT_FOUND', 404);
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
server.listen(0, '127.0.0.1');
|
|
409
|
+
await once(server, 'listening');
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
await store?.close();
|
|
413
|
+
throw storageError(error);
|
|
414
|
+
}
|
|
415
|
+
const address = server.address();
|
|
416
|
+
if (!address || typeof address === 'string') {
|
|
417
|
+
server.close();
|
|
418
|
+
await store?.close();
|
|
419
|
+
throw new StudioError('STUDIO_STORAGE_FAILED', 500);
|
|
420
|
+
}
|
|
421
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
422
|
+
let closePromise;
|
|
423
|
+
return { origin, launchUrl: `${origin}/studio/#launch=${session.launch}`, close() {
|
|
424
|
+
return closePromise ??= (async () => {
|
|
425
|
+
closing = true;
|
|
426
|
+
saves.invalidate();
|
|
427
|
+
const stopped = new Promise(resolve => server.close(() => resolve()));
|
|
428
|
+
server.closeAllConnections();
|
|
429
|
+
await stopped;
|
|
430
|
+
await Promise.allSettled([...active]);
|
|
431
|
+
try {
|
|
432
|
+
await saveWorker?.close();
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
await store?.close();
|
|
436
|
+
}
|
|
437
|
+
})();
|
|
438
|
+
} };
|
|
439
|
+
}
|
|
440
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { StudioError } from './errors.js';
|
|
3
|
+
function equal(a, b) {
|
|
4
|
+
return typeof a === 'string' && /^[0-9a-f]{64}$/.test(a) && timingSafeEqual(Buffer.from(a, 'ascii'), Buffer.from(b, 'ascii'));
|
|
5
|
+
}
|
|
6
|
+
export function createStudioSession() {
|
|
7
|
+
const launch = randomBytes(32).toString('hex');
|
|
8
|
+
const session = randomBytes(32).toString('hex');
|
|
9
|
+
const cookieName = `lkstudio_${randomBytes(8).toString('hex')}`;
|
|
10
|
+
let consumed = false;
|
|
11
|
+
return {
|
|
12
|
+
launch,
|
|
13
|
+
bootstrap(value) {
|
|
14
|
+
if (consumed || !equal(value, launch))
|
|
15
|
+
throw new StudioError('STUDIO_SESSION_REQUIRED', 401);
|
|
16
|
+
consumed = true;
|
|
17
|
+
return `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/studio`;
|
|
18
|
+
},
|
|
19
|
+
assert(cookie) {
|
|
20
|
+
const values = (cookie ?? '').split(';').map(v => v.trim()).filter(v => v.startsWith(`${cookieName}=`));
|
|
21
|
+
if (!consumed || values.length !== 1 || !equal(values[0].slice(cookieName.length + 1), session))
|
|
22
|
+
throw new StudioError('STUDIO_SESSION_REQUIRED', 401);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const studioHtml = "<!doctype html>\n<html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"studio-style-nonce\" content=\"__STUDIO_STYLE_NONCE__\"><title>LumenKey \u672C\u5730\u5DE5\u4F5C\u53F0</title><link rel=\"stylesheet\" href=\"/studio/style.css\"><link rel=\"stylesheet\" href=\"/studio/canvas/editor.css\"></head>\n<body><div class=\"app-shell\">\n<aside class=\"sidebar\"><div class=\"brand\"><b>\u5149\u94A5</b><small>LumenKey Studio \u00B7 \u672C\u5730</small></div>\n<nav aria-label=\"\u5DE5\u4F5C\u53F0\u5BFC\u822A\"><button data-view=\"create\" data-mode=\"image\" class=\"selected\">\u56FE\u50CF\u521B\u4F5C</button><button data-view=\"create\" data-mode=\"video\">\u89C6\u9891\u521B\u4F5C</button><button data-view=\"canvas\">\u753B\u5E03</button><button data-view=\"tasks\">\u751F\u6210\u4EFB\u52A1</button><button data-view=\"assets\">\u672C\u673A\u7D20\u6750</button></nav>\n<div class=\"sidebar-bottom\"><small>\u672C\u673A\u7F16\u8F91 \u00B7 \u4E91\u7AEF\u8BA1\u7B97</small><button id=\"theme\" class=\"quiet\">\u5207\u6362\u660E\u6697</button></div></aside>\n<main class=\"workspace\"><div class=\"runtime-header\"><header class=\"topbar\"><span>\u5DE5\u4F5C\u53F0 <span class=\"muted\">/</span> <b id=\"crumb\">\u56FE\u50CF\u521B\u4F5C</b></span><div class=\"top-actions\"><span id=\"balance\" class=\"muted\">\u7B97\u529B\u672A\u77E5</span><button id=\"connect\" class=\"quiet\">\u8FDE\u63A5\u4E91\u7AEF</button><button id=\"disconnect\" class=\"quiet\" hidden>\u65AD\u5F00</button></div></header>\n<div class=\"location runtime-status\" aria-label=\"\u5DE5\u4F5C\u533A\u72B6\u6001\"><div><small>\u4FDD\u5B58\u4F4D\u7F6E</small><details id=\"storage-location\" hidden><summary><span id=\"location-short\"></span><span class=\"hint\"> \u00B7 \u5C55\u5F00\u5B8C\u6574\u8DEF\u5F84</span></summary><code id=\"location\"></code></details><p id=\"storage-warning\">\u6B63\u5728\u8BFB\u53D6\u4FDD\u5B58\u4F4D\u7F6E\u2026</p></div><div><small>\u672C\u673A\u6388\u6743</small><b id=\"local-status\">\u6B63\u5728\u6838\u5BF9\u672C\u673A\u6388\u6743\u2026</b></div><div><small>\u4E91\u8FDE\u63A5</small><b id=\"gateway\">\u4E91\u7AEF\u672A\u8FDE\u63A5</b><span id=\"gateway-detail\" class=\"hint\">\u79BB\u7EBF\u53EF\u7F16\u8F91\uFF1B\u4E91\u7AEF\u751F\u6210\u9700\u8981\u8FDE\u63A5\u3002</span></div></div></div>\n<div class=\"runtime-note\">\u5148\u7F16\u8F91\u5185\u5BB9\uFF0C\u518D\u67E5\u770B\u62A5\u4EF7\u3002\u53EA\u6709\u786E\u8BA4\u63D0\u4EA4\u624D\u4F1A\u521B\u5EFA\u4E91\u7AEF\u4EFB\u52A1\u3002</div>\n<div class=\"view\"><div id=\"error\" class=\"error\" role=\"alert\" hidden></div><p id=\"notice\" role=\"status\" class=\"notice\" hidden></p>\n<section id=\"create-view\"><div class=\"heading\"><div><h1 id=\"title\">\u56FE\u50CF\u521B\u4F5C</h1><p>\u63CF\u8FF0\u4F60\u7684\u60F3\u6CD5\uFF0C\u5148\u67E5\u770B\u62A5\u4EF7\uFF0C\u518D\u51B3\u5B9A\u662F\u5426\u751F\u6210\u3002</p></div><button id=\"save-draft\" class=\"quiet\" disabled>\u4FDD\u5B58\u8349\u7A3F</button></div>\n<div class=\"create-panel\"><section class=\"composer\"><div class=\"section-title\"><h2>\u521B\u4F5C\u8F93\u5165</h2><span id=\"draft-status\" class=\"muted\">\u672A\u4FDD\u5B58</span></div><div id=\"prompt-fields\"></div><p class=\"hint\">\u7559\u7A7A\u9879\u6309\u5E73\u53F0\u76EE\u5F55\u89C4\u5219\u5904\u7406\uFF1B\u672C\u673A\u6587\u4EF6\u4E0D\u4F1A\u81EA\u52A8\u4E0A\u4F20\u3002</p><p id=\"draft-error\" class=\"error\" hidden></p><button id=\"reload-draft\" class=\"quiet\" hidden>\u8BFB\u53D6\u6700\u65B0\u8349\u7A3F\u7248\u672C\uFF08\u4FDD\u7559\u5F53\u524D\u7F16\u8F91\uFF09</button></section>\n<aside class=\"settings\"><h2>\u5E38\u7528\u504F\u597D</h2><label for=\"model\">\u6A21\u578B</label><select id=\"model\" disabled><option value=\"\">\u8FDE\u63A5\u4E91\u7AEF\u540E\u52A0\u8F7D\u76EE\u5F55</option></select><p id=\"catalog-status\" class=\"hint\">\u5C1A\u672A\u8FDE\u63A5</p><button id=\"reload-cloud\" class=\"quiet\" disabled>\u5237\u65B0\u76EE\u5F55\u4E0E\u4F59\u989D</button><div id=\"parameter-fields\"></div><details id=\"advanced-parameters\" hidden><summary>\u66F4\u591A\u53C2\u6570 <span class=\"hint\">JSON \u7B49\u590D\u6742\u8F93\u5165</span></summary><div id=\"advanced-fields\"></div></details><div class=\"quote-area\"><p id=\"quote-hint\" class=\"hint\">\u53EA\u62A5\u4EF7\uFF0C\u4E0D\u4F1A\u521B\u5EFA\u751F\u6210\u4EFB\u52A1\u3002</p><button id=\"quote\" class=\"primary\" disabled>\u67E5\u770B\u62A5\u4EF7</button></div></aside>\n<section class=\"output\"><div class=\"section-title\"><h2>\u672C\u6B21\u62A5\u4EF7\u4E0E\u7ED3\u679C</h2></div><div id=\"current-run\" class=\"empty\">\u586B\u5199\u8F93\u5165\u5E76\u9009\u62E9\u6A21\u578B\u540E\u67E5\u770B\u62A5\u4EF7\u3002</div><button id=\"review-quote\" class=\"primary\" hidden>\u6838\u5BF9\u5E76\u786E\u8BA4\u751F\u6210</button></section></div></section>\n<section id=\"canvas-view\" hidden aria-label=\"\u672C\u673A\u753B\u5E03\"></section>\n<section id=\"tasks-view\" hidden><div class=\"heading\"><div><h1>\u751F\u6210\u4EFB\u52A1</h1><p>\u5F53\u524D\u8FDE\u63A5\u7684\u672C\u5730\u4EFB\u52A1\u8BB0\u5F55\u3002\u5237\u65B0\u53EA\u67E5\u8BE2\u72B6\u6001\uFF0C\u4E0D\u4F1A\u91CD\u65B0\u4E0B\u5355\u3002</p></div><button id=\"refresh-tasks\" class=\"quiet\">\u5237\u65B0\u4EFB\u52A1\u5217\u8868</button></div><section class=\"save-binding\" aria-label=\"\u8F93\u51FA\u4FDD\u5B58\u8FDE\u63A5\"><p id=\"save-binding-status\" role=\"status\" class=\"hint\"></p><code id=\"save-binding-path\" hidden></code><button id=\"rebind-saves\" class=\"quiet\" disabled>\u91CD\u65B0\u6838\u5BF9\u4FDD\u5B58\u8FDE\u63A5</button></section><p id=\"tasks-status\" role=\"status\" class=\"hint\"></p><div id=\"tasks\"></div></section>\n<section id=\"assets-view\" hidden><div class=\"heading\"><div><h1>\u672C\u673A\u7D20\u6750</h1><p>\u8FD9\u91CC\u4EC5\u663E\u793A\u5DF2\u7ECF\u5199\u5165\u6240\u9009\u76EE\u5F55\u7684\u6587\u4EF6\u3002</p></div><button id=\"refresh-assets\" class=\"quiet\">\u5237\u65B0\u7D20\u6750</button></div><form id=\"save-asset\" class=\"import\"><label for=\"file\">\u5BFC\u5165\u5DF2\u6709\u56FE\u7247\u6216\u89C6\u9891</label><input id=\"file\" type=\"file\" accept=\"image/png,image/jpeg,image/webp,video/mp4,video/webm\"><button id=\"save-asset-button\" class=\"quiet\" disabled>\u4FDD\u5B58\u5230\u672C\u673A\u76EE\u5F55</button></form><p id=\"asset-message\" role=\"status\" class=\"hint\"></p><p id=\"assets-status\" role=\"status\" class=\"hint\"></p><div id=\"assets\" class=\"assets-grid\"></div><p id=\"issues\" class=\"error\" hidden></p></section>\n<footer>\u8349\u7A3F\u548C\u4EFB\u52A1\u8F93\u5165\u4FDD\u5B58\u5728\u672C\u673A\u76EE\u5F55\uFF0C\u672A\u52A0\u5BC6\u3002\u4E91\u7AEF\u5B8C\u6210\u4E0D\u7B49\u4E8E\u5DF2\u4FDD\u5B58\u5230\u672C\u673A\uFF1B\u672C\u5730\u72B6\u6001\u4E0D\u8BC1\u660E\u6263\u8D39\u6216\u9000\u6B3E\u3002</footer></div></main></div>\n<dialog id=\"confirm-dialog\" aria-labelledby=\"confirm-title\"><h2 id=\"confirm-title\">\u786E\u8BA4\u672C\u6B21\u751F\u6210</h2><p id=\"confirm-summary\"></p><p class=\"hint\">\u5C06\u4F7F\u7528\u5DF2\u62A5\u4EF7\u7684\u8F93\u5165\u3002\u5B9E\u9645\u6263\u8D39\u4E0E\u5931\u8D25\u91CA\u653E\u4EE5\u5E73\u53F0\u8D26\u672C\u4E3A\u51C6\u3002</p><div class=\"dialog-actions\"><button id=\"cancel-confirm\" class=\"quiet\">\u6682\u4E0D\u63D0\u4EA4</button><button id=\"confirm\" class=\"primary\">\u786E\u8BA4\u63D0\u4EA4</button></div></dialog>\n<script type=\"module\" src=\"/studio/app.js\"></script></body></html>";
|
|
2
|
+
export declare const studioScript = "import './web/app.js';";
|
|
3
|
+
//# sourceMappingURL=shell.d.ts.map
|