@sorb/juice 0.1.0
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 +53 -0
- package/dist/cli.js +1209 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.js +989 -0
- package/dist/index.js.map +7 -0
- package/dist/migrations/001_init.sql +106 -0
- package/package.json +49 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const __sorbModuleUrl = require('url').pathToFileURL(require('path').join(__dirname, 'migrate.js')).href;
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __esm = (fn, res) => function __init() {
|
|
10
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
var __copyProps = (to, from, except, desc) => {
|
|
17
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
|
+
for (let key of __getOwnPropNames(from))
|
|
19
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
20
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
21
|
+
}
|
|
22
|
+
return to;
|
|
23
|
+
};
|
|
24
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
25
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
26
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
27
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
28
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
29
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
30
|
+
mod
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
// src/store/redis.js
|
|
34
|
+
var redis_exports = {};
|
|
35
|
+
__export(redis_exports, {
|
|
36
|
+
createRedisStore: () => createRedisStore,
|
|
37
|
+
default: () => redis_default
|
|
38
|
+
});
|
|
39
|
+
var import_ioredis, PREVIEW_PREFIX, VERIFY_PREFIX, VERIFY_LATEST_KEY, previewKey, verifyKey, scanCount, createRedisStore, redis_default;
|
|
40
|
+
var init_redis = __esm({
|
|
41
|
+
"src/store/redis.js"() {
|
|
42
|
+
import_ioredis = __toESM(require("ioredis"));
|
|
43
|
+
PREVIEW_PREFIX = "preview:";
|
|
44
|
+
VERIFY_PREFIX = "verify:";
|
|
45
|
+
VERIFY_LATEST_KEY = "verify:latest";
|
|
46
|
+
previewKey = (id) => PREVIEW_PREFIX + id;
|
|
47
|
+
verifyKey = (id) => VERIFY_PREFIX + id;
|
|
48
|
+
scanCount = async (client, match) => {
|
|
49
|
+
let cursor = "0";
|
|
50
|
+
let total = 0;
|
|
51
|
+
do {
|
|
52
|
+
const [next, keys] = await client.scan(cursor, "MATCH", match, "COUNT", 100);
|
|
53
|
+
cursor = next;
|
|
54
|
+
total += keys.length;
|
|
55
|
+
} while (cursor !== "0");
|
|
56
|
+
return total;
|
|
57
|
+
};
|
|
58
|
+
createRedisStore = async (config) => {
|
|
59
|
+
const ttlMs = config.previewTtlMs;
|
|
60
|
+
const client = new import_ioredis.default(config.redisUrl, {
|
|
61
|
+
lazyConnect: true,
|
|
62
|
+
maxRetriesPerRequest: 2
|
|
63
|
+
});
|
|
64
|
+
client.on("error", () => {
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
await client.connect();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
}
|
|
70
|
+
const putPreview = async (id, tokens) => {
|
|
71
|
+
const entry = { tokens, createdAt: Date.now() };
|
|
72
|
+
await client.set(previewKey(id), JSON.stringify(entry), "PX", ttlMs);
|
|
73
|
+
};
|
|
74
|
+
const getPreview = async (id) => {
|
|
75
|
+
const raw = await client.get(previewKey(id));
|
|
76
|
+
if (raw == null) return null;
|
|
77
|
+
return JSON.parse(raw);
|
|
78
|
+
};
|
|
79
|
+
const hasPreview = async (id) => {
|
|
80
|
+
return await client.exists(previewKey(id)) === 1;
|
|
81
|
+
};
|
|
82
|
+
const updatePreview = async (id, tokens) => {
|
|
83
|
+
if (!await hasPreview(id)) return false;
|
|
84
|
+
const entry = { tokens, createdAt: Date.now() };
|
|
85
|
+
await client.set(previewKey(id), JSON.stringify(entry), "PX", ttlMs);
|
|
86
|
+
return true;
|
|
87
|
+
};
|
|
88
|
+
const deletePreview = async (id) => {
|
|
89
|
+
await client.del(previewKey(id));
|
|
90
|
+
};
|
|
91
|
+
const countPreviews = async () => {
|
|
92
|
+
return scanCount(client, PREVIEW_PREFIX + "*");
|
|
93
|
+
};
|
|
94
|
+
const putVerification = async (id, { storyId, bbox, meta }) => {
|
|
95
|
+
const entry = { storyId, bbox, meta, createdAt: Date.now() };
|
|
96
|
+
const pipeline = client.multi();
|
|
97
|
+
pipeline.set(verifyKey(id), JSON.stringify(entry), "PX", ttlMs);
|
|
98
|
+
pipeline.set(VERIFY_LATEST_KEY, id, "PX", ttlMs);
|
|
99
|
+
await pipeline.exec();
|
|
100
|
+
};
|
|
101
|
+
const getVerification = async (id) => {
|
|
102
|
+
const raw = await client.get(verifyKey(id));
|
|
103
|
+
if (raw == null) return null;
|
|
104
|
+
return JSON.parse(raw);
|
|
105
|
+
};
|
|
106
|
+
const getLatestVerification = async () => {
|
|
107
|
+
const id = await client.get(VERIFY_LATEST_KEY);
|
|
108
|
+
if (id == null) return null;
|
|
109
|
+
return getVerification(id);
|
|
110
|
+
};
|
|
111
|
+
const countVerifications = async () => {
|
|
112
|
+
const total = await scanCount(client, VERIFY_PREFIX + "*");
|
|
113
|
+
const hasLatest = await client.exists(VERIFY_LATEST_KEY) === 1;
|
|
114
|
+
return hasLatest ? Math.max(0, total - 1) : total;
|
|
115
|
+
};
|
|
116
|
+
const ping = async () => {
|
|
117
|
+
try {
|
|
118
|
+
return await client.ping() === "PONG";
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const close2 = async () => {
|
|
124
|
+
try {
|
|
125
|
+
await client.quit();
|
|
126
|
+
} catch (e) {
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
putPreview,
|
|
131
|
+
getPreview,
|
|
132
|
+
hasPreview,
|
|
133
|
+
updatePreview,
|
|
134
|
+
deletePreview,
|
|
135
|
+
countPreviews,
|
|
136
|
+
putVerification,
|
|
137
|
+
getVerification,
|
|
138
|
+
getLatestVerification,
|
|
139
|
+
countVerifications,
|
|
140
|
+
ping,
|
|
141
|
+
close: close2
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
redis_default = createRedisStore;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// src/cli.js
|
|
149
|
+
var import_commander = require("commander");
|
|
150
|
+
var import_node_server = require("@hono/node-server");
|
|
151
|
+
var import_fs3 = require("fs");
|
|
152
|
+
var import_path3 = require("path");
|
|
153
|
+
var import_picocolors4 = __toESM(require("picocolors"));
|
|
154
|
+
|
|
155
|
+
// src/server.js
|
|
156
|
+
var import_hono = require("hono");
|
|
157
|
+
var import_cors = require("hono/cors");
|
|
158
|
+
var import_nanoid = require("nanoid");
|
|
159
|
+
|
|
160
|
+
// src/auth.js
|
|
161
|
+
var import_node_crypto = require("node:crypto");
|
|
162
|
+
var SCOPE = Object.freeze({ READ: "read", WRITE: "write" });
|
|
163
|
+
function hashKey(raw) {
|
|
164
|
+
return (0, import_node_crypto.createHash)("sha256").update(raw, "utf8").digest("hex");
|
|
165
|
+
}
|
|
166
|
+
function parseBearer(authHeader) {
|
|
167
|
+
if (typeof authHeader !== "string") return null;
|
|
168
|
+
const trimmed = authHeader.trim();
|
|
169
|
+
if (trimmed === "") return null;
|
|
170
|
+
const match = /^(\S+)\s+(.*)$/.exec(trimmed);
|
|
171
|
+
if (!match) return null;
|
|
172
|
+
const scheme = match[1];
|
|
173
|
+
if (scheme.toLowerCase() !== "bearer") return null;
|
|
174
|
+
const token = match[2].trim();
|
|
175
|
+
if (token === "") return null;
|
|
176
|
+
return token;
|
|
177
|
+
}
|
|
178
|
+
async function resolveApiKey(db, authHeader) {
|
|
179
|
+
const raw = parseBearer(authHeader);
|
|
180
|
+
if (raw === null) return null;
|
|
181
|
+
const hash = hashKey(raw);
|
|
182
|
+
const result = await db.query(
|
|
183
|
+
`SELECT k.id AS key_id, k.type, k.project_id, p.org_id, p.namespace, p.allowed_origins
|
|
184
|
+
FROM api_keys k
|
|
185
|
+
JOIN projects p ON p.id = k.project_id
|
|
186
|
+
WHERE k.hash = $1 AND k.revoked_at IS NULL
|
|
187
|
+
LIMIT 1`,
|
|
188
|
+
[hash]
|
|
189
|
+
);
|
|
190
|
+
const rows = result && result.rows || [];
|
|
191
|
+
if (rows.length === 0) return null;
|
|
192
|
+
const row = rows[0];
|
|
193
|
+
const type = row.type;
|
|
194
|
+
const scope = type === "publishable" ? SCOPE.READ : SCOPE.WRITE;
|
|
195
|
+
const allowedOrigins = Array.isArray(row.allowed_origins) ? row.allowed_origins : [];
|
|
196
|
+
return Object.freeze({
|
|
197
|
+
keyId: row.key_id,
|
|
198
|
+
type,
|
|
199
|
+
projectId: row.project_id,
|
|
200
|
+
orgId: row.org_id,
|
|
201
|
+
namespace: row.namespace,
|
|
202
|
+
allowedOrigins,
|
|
203
|
+
scope
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/entitlements.js
|
|
208
|
+
var FREE = Object.freeze({
|
|
209
|
+
plan: "free",
|
|
210
|
+
status: "active",
|
|
211
|
+
seats: 1,
|
|
212
|
+
previewPersistence: false,
|
|
213
|
+
previewSharing: false,
|
|
214
|
+
maxProjects: 1,
|
|
215
|
+
maxActivePreviews: -1,
|
|
216
|
+
captureEnabled: false
|
|
217
|
+
});
|
|
218
|
+
var numOr = (v, fallback) => {
|
|
219
|
+
const n = Number(v);
|
|
220
|
+
return Number.isFinite(n) ? n : fallback;
|
|
221
|
+
};
|
|
222
|
+
function normalizeEntitlements(row) {
|
|
223
|
+
const r = row || {};
|
|
224
|
+
let data = r.data;
|
|
225
|
+
if (typeof data === "string") {
|
|
226
|
+
try {
|
|
227
|
+
data = JSON.parse(data);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
data = null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (data === null || data === void 0 || typeof data !== "object") data = {};
|
|
233
|
+
const out = {
|
|
234
|
+
plan: FREE.plan,
|
|
235
|
+
status: FREE.status,
|
|
236
|
+
seats: "seats" in data ? Math.max(1, numOr(data.seats, FREE.seats)) : FREE.seats,
|
|
237
|
+
previewPersistence: "previewPersistence" in data ? Boolean(data.previewPersistence) : FREE.previewPersistence,
|
|
238
|
+
previewSharing: "previewSharing" in data ? Boolean(data.previewSharing) : FREE.previewSharing,
|
|
239
|
+
maxProjects: "maxProjects" in data ? numOr(data.maxProjects, FREE.maxProjects) : FREE.maxProjects,
|
|
240
|
+
maxActivePreviews: "maxActivePreviews" in data ? numOr(data.maxActivePreviews, FREE.maxActivePreviews) : FREE.maxActivePreviews,
|
|
241
|
+
captureEnabled: "captureEnabled" in data ? Boolean(data.captureEnabled) : FREE.captureEnabled
|
|
242
|
+
};
|
|
243
|
+
if (r.plan !== void 0 && r.plan !== null && String(r.plan).trim() !== "") {
|
|
244
|
+
out.plan = /** @type {Entitlements['plan']} */
|
|
245
|
+
String(r.plan);
|
|
246
|
+
}
|
|
247
|
+
if (r.status !== void 0 && r.status !== null && String(r.status).trim() !== "") {
|
|
248
|
+
out.status = /** @type {Entitlements['status']} */
|
|
249
|
+
String(r.status);
|
|
250
|
+
}
|
|
251
|
+
return Object.freeze(out);
|
|
252
|
+
}
|
|
253
|
+
async function getEntitlements(db, orgId) {
|
|
254
|
+
const { rows } = await db.query(
|
|
255
|
+
"SELECT plan, status, data FROM entitlements WHERE org_id = $1 LIMIT 1",
|
|
256
|
+
[orgId]
|
|
257
|
+
);
|
|
258
|
+
if (!rows || rows.length === 0) return FREE;
|
|
259
|
+
return normalizeEntitlements(rows[0]);
|
|
260
|
+
}
|
|
261
|
+
function effectiveEntitlements(ent) {
|
|
262
|
+
if (ent && (ent.status === "past_due" || ent.status === "canceled")) {
|
|
263
|
+
return Object.freeze({
|
|
264
|
+
...ent,
|
|
265
|
+
// Overlay FREE's gate fields; keep plan + status from ent.
|
|
266
|
+
seats: FREE.seats,
|
|
267
|
+
previewPersistence: FREE.previewPersistence,
|
|
268
|
+
previewSharing: FREE.previewSharing,
|
|
269
|
+
maxProjects: FREE.maxProjects,
|
|
270
|
+
maxActivePreviews: FREE.maxActivePreviews,
|
|
271
|
+
captureEnabled: FREE.captureEnabled,
|
|
272
|
+
plan: ent.plan,
|
|
273
|
+
status: ent.status
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return ent;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/server.js
|
|
280
|
+
var createServer = ({
|
|
281
|
+
store,
|
|
282
|
+
config,
|
|
283
|
+
db = null,
|
|
284
|
+
getLatestTokens = () => ({}),
|
|
285
|
+
getResolvedTokens = () => null,
|
|
286
|
+
getArtifactIndex = () => null,
|
|
287
|
+
getArtifact = () => null,
|
|
288
|
+
onError = () => {
|
|
289
|
+
}
|
|
290
|
+
}) => {
|
|
291
|
+
const namespace = config.namespace;
|
|
292
|
+
const corsOrigin = config.corsOrigins && config.corsOrigins !== "*" ? config.corsOrigins : "*";
|
|
293
|
+
const hosted = Boolean(config.databaseUrl);
|
|
294
|
+
const upgradeUrl = process.env.SORB_UPGRADE_URL || "/billing";
|
|
295
|
+
const previewTtlMs = config.previewTtlMs || 864e5;
|
|
296
|
+
const app = new import_hono.Hono();
|
|
297
|
+
if (hosted) {
|
|
298
|
+
app.use("*", async (c, next) => {
|
|
299
|
+
const path = c.req.path;
|
|
300
|
+
if (path === "/health" || path === "/ready") return next();
|
|
301
|
+
if (c.req.method === "OPTIONS") return next();
|
|
302
|
+
let ctx;
|
|
303
|
+
try {
|
|
304
|
+
ctx = await resolveApiKey(db, c.req.header("Authorization"));
|
|
305
|
+
} catch (e) {
|
|
306
|
+
onError(e, { at: "auth" });
|
|
307
|
+
return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
|
|
308
|
+
}
|
|
309
|
+
if (!ctx) {
|
|
310
|
+
return c.json({ error: "Unauthorized", code: "unauthorized" }, 401);
|
|
311
|
+
}
|
|
312
|
+
c.set("auth", ctx);
|
|
313
|
+
let ent;
|
|
314
|
+
try {
|
|
315
|
+
ent = effectiveEntitlements(await getEntitlements(db, ctx.orgId));
|
|
316
|
+
} catch (e) {
|
|
317
|
+
onError(e, { at: "entitlements" });
|
|
318
|
+
return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
|
|
319
|
+
}
|
|
320
|
+
c.set("ent", ent);
|
|
321
|
+
return next();
|
|
322
|
+
});
|
|
323
|
+
app.use(
|
|
324
|
+
"*",
|
|
325
|
+
(0, import_cors.cors)({
|
|
326
|
+
origin: (origin, c) => {
|
|
327
|
+
if (!origin) return origin;
|
|
328
|
+
if (c.req.method === "OPTIONS") return origin;
|
|
329
|
+
const ctx = c.get("auth");
|
|
330
|
+
if (ctx && Array.isArray(ctx.allowedOrigins) && ctx.allowedOrigins.includes(origin)) {
|
|
331
|
+
return origin;
|
|
332
|
+
}
|
|
333
|
+
return void 0;
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
app.use("*", (0, import_cors.cors)({ origin: corsOrigin }));
|
|
339
|
+
}
|
|
340
|
+
const requireWrite = (c) => {
|
|
341
|
+
if (!hosted) return null;
|
|
342
|
+
const ctx = c.get("auth");
|
|
343
|
+
if (ctx.scope !== SCOPE.WRITE) {
|
|
344
|
+
return c.json({ error: "Publishable keys are read-only", code: "read_only" }, 403);
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
};
|
|
348
|
+
const activePreviewCount = async (projectId) => {
|
|
349
|
+
const res = await db.query(
|
|
350
|
+
"SELECT count(*)::int AS n FROM previews WHERE project_id = $1 AND (expires_at IS NULL OR expires_at > now())",
|
|
351
|
+
[projectId]
|
|
352
|
+
);
|
|
353
|
+
const row = res && res.rows && res.rows[0];
|
|
354
|
+
return row ? Number(row.n) || 0 : 0;
|
|
355
|
+
};
|
|
356
|
+
const requireSharing = (c) => {
|
|
357
|
+
const ent = c.get("ent");
|
|
358
|
+
if (!ent || ent.previewSharing !== true) {
|
|
359
|
+
return c.json({ error: "Preview sharing is not available on your plan", code: "sharing_locked", upgradeUrl }, 402);
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
};
|
|
363
|
+
app.post("/preview", async (c) => {
|
|
364
|
+
if (hosted) {
|
|
365
|
+
const denied = requireWrite(c);
|
|
366
|
+
if (denied) return denied;
|
|
367
|
+
const ctx = c.get("auth");
|
|
368
|
+
const ent = c.get("ent");
|
|
369
|
+
if (c.req.query("share") === "1") {
|
|
370
|
+
const locked = requireSharing(c);
|
|
371
|
+
if (locked) return locked;
|
|
372
|
+
}
|
|
373
|
+
if (ent.maxActivePreviews !== -1) {
|
|
374
|
+
let active;
|
|
375
|
+
try {
|
|
376
|
+
active = await activePreviewCount(ctx.projectId);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
onError(e, { at: "preview.count" });
|
|
379
|
+
return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
|
|
380
|
+
}
|
|
381
|
+
if (active >= ent.maxActivePreviews) {
|
|
382
|
+
return c.json(
|
|
383
|
+
{
|
|
384
|
+
error: `Active preview limit reached (${ent.maxActivePreviews}). Upgrade for more concurrent previews.`,
|
|
385
|
+
code: "preview_limit",
|
|
386
|
+
upgradeUrl
|
|
387
|
+
},
|
|
388
|
+
402
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const tokens2 = await c.req.json();
|
|
393
|
+
const id2 = (0, import_nanoid.nanoid)(8);
|
|
394
|
+
const ttlMs = ent.previewPersistence ? null : previewTtlMs;
|
|
395
|
+
await store.putPreview(id2, tokens2, ttlMs);
|
|
396
|
+
const expiresAt = ttlMs == null ? null : new Date(Date.now() + ttlMs);
|
|
397
|
+
try {
|
|
398
|
+
await db.query(
|
|
399
|
+
"INSERT INTO previews (id, project_id, expires_at) VALUES ($1, $2, $3)",
|
|
400
|
+
[id2, ctx.projectId, expiresAt]
|
|
401
|
+
);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
onError(e, { at: "preview.bookkeeping.insert", id: id2 });
|
|
404
|
+
}
|
|
405
|
+
const url2 = `?preview=${id2}`;
|
|
406
|
+
return c.json({ id: id2, url: url2 });
|
|
407
|
+
}
|
|
408
|
+
const tokens = await c.req.json();
|
|
409
|
+
const id = (0, import_nanoid.nanoid)(8);
|
|
410
|
+
await store.putPreview(id, tokens);
|
|
411
|
+
const url = `?preview=${id}`;
|
|
412
|
+
return c.json({ id, url });
|
|
413
|
+
});
|
|
414
|
+
app.get("/preview/:id", async (c) => {
|
|
415
|
+
const id = c.req.param("id");
|
|
416
|
+
const entry = await store.getPreview(id);
|
|
417
|
+
if (!entry) {
|
|
418
|
+
return c.json({ error: "Preview not found or expired" }, 404);
|
|
419
|
+
}
|
|
420
|
+
if (hosted) {
|
|
421
|
+
const ctx = c.get("auth");
|
|
422
|
+
let owned = false;
|
|
423
|
+
try {
|
|
424
|
+
const res = await db.query(
|
|
425
|
+
"SELECT 1 FROM previews WHERE id = $1 AND project_id = $2 LIMIT 1",
|
|
426
|
+
[id, ctx.projectId]
|
|
427
|
+
);
|
|
428
|
+
owned = Boolean(res && res.rows && res.rows.length);
|
|
429
|
+
} catch (e) {
|
|
430
|
+
onError(e, { at: "preview.owner", method: "GET", id });
|
|
431
|
+
return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
|
|
432
|
+
}
|
|
433
|
+
if (!owned) {
|
|
434
|
+
return c.json({ error: "Preview not found or expired" }, 404);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return c.json(entry.tokens);
|
|
438
|
+
});
|
|
439
|
+
app.put("/preview/:id", async (c) => {
|
|
440
|
+
const id = c.req.param("id");
|
|
441
|
+
if (hosted) {
|
|
442
|
+
const denied = requireWrite(c);
|
|
443
|
+
if (denied) return denied;
|
|
444
|
+
const ctx = c.get("auth");
|
|
445
|
+
let owned = false;
|
|
446
|
+
try {
|
|
447
|
+
const res = await db.query(
|
|
448
|
+
"SELECT 1 FROM previews WHERE id = $1 AND project_id = $2 LIMIT 1",
|
|
449
|
+
[id, ctx.projectId]
|
|
450
|
+
);
|
|
451
|
+
owned = Boolean(res && res.rows && res.rows.length);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
onError(e, { at: "preview.owner", method: "PUT", id });
|
|
454
|
+
return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
|
|
455
|
+
}
|
|
456
|
+
if (!owned) {
|
|
457
|
+
return c.json({ error: "Preview not found" }, 404);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const tokens = await c.req.json();
|
|
461
|
+
const updated = await store.updatePreview(id, tokens);
|
|
462
|
+
if (!updated) {
|
|
463
|
+
return c.json({ error: "Preview not found" }, 404);
|
|
464
|
+
}
|
|
465
|
+
return c.json({ id, updated: true });
|
|
466
|
+
});
|
|
467
|
+
app.delete("/preview/:id", async (c) => {
|
|
468
|
+
const id = c.req.param("id");
|
|
469
|
+
if (hosted) {
|
|
470
|
+
const denied = requireWrite(c);
|
|
471
|
+
if (denied) return denied;
|
|
472
|
+
const ctx = c.get("auth");
|
|
473
|
+
let owned = false;
|
|
474
|
+
try {
|
|
475
|
+
const res = await db.query(
|
|
476
|
+
"SELECT 1 FROM previews WHERE id = $1 AND project_id = $2 LIMIT 1",
|
|
477
|
+
[id, ctx.projectId]
|
|
478
|
+
);
|
|
479
|
+
owned = Boolean(res && res.rows && res.rows.length);
|
|
480
|
+
} catch (e) {
|
|
481
|
+
onError(e, { at: "preview.owner", method: "DELETE", id });
|
|
482
|
+
return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
|
|
483
|
+
}
|
|
484
|
+
if (!owned) {
|
|
485
|
+
return c.json({ error: "Preview not found" }, 404);
|
|
486
|
+
}
|
|
487
|
+
await store.deletePreview(id);
|
|
488
|
+
try {
|
|
489
|
+
await db.query("DELETE FROM previews WHERE id = $1 AND project_id = $2", [id, ctx.projectId]);
|
|
490
|
+
} catch (e) {
|
|
491
|
+
onError(e, { at: "preview.bookkeeping.delete", id });
|
|
492
|
+
}
|
|
493
|
+
return c.json({ deleted: true });
|
|
494
|
+
}
|
|
495
|
+
await store.deletePreview(id);
|
|
496
|
+
return c.json({ deleted: true });
|
|
497
|
+
});
|
|
498
|
+
app.post("/verify", async (c) => {
|
|
499
|
+
if (hosted) {
|
|
500
|
+
const denied = requireWrite(c);
|
|
501
|
+
if (denied) return denied;
|
|
502
|
+
}
|
|
503
|
+
const { storyId, bbox, meta } = await c.req.json();
|
|
504
|
+
const id = (0, import_nanoid.nanoid)(8);
|
|
505
|
+
await store.putVerification(id, { storyId, bbox, meta });
|
|
506
|
+
return c.json({ id });
|
|
507
|
+
});
|
|
508
|
+
app.get("/verify/latest", async (c) => {
|
|
509
|
+
const entry = await store.getLatestVerification();
|
|
510
|
+
if (!entry) {
|
|
511
|
+
return c.json({ error: "No verification reported yet" }, 404);
|
|
512
|
+
}
|
|
513
|
+
return c.json(entry);
|
|
514
|
+
});
|
|
515
|
+
app.get("/verify/:id", async (c) => {
|
|
516
|
+
const entry = await store.getVerification(c.req.param("id"));
|
|
517
|
+
if (!entry) {
|
|
518
|
+
return c.json({ error: "Verification not found or expired" }, 404);
|
|
519
|
+
}
|
|
520
|
+
return c.json(entry);
|
|
521
|
+
});
|
|
522
|
+
app.get("/tokens/latest", (c) => {
|
|
523
|
+
return c.json(getLatestTokens());
|
|
524
|
+
});
|
|
525
|
+
app.get("/tokens/resolved", (c) => {
|
|
526
|
+
const resolved = getResolvedTokens();
|
|
527
|
+
if (!resolved) {
|
|
528
|
+
return c.json(
|
|
529
|
+
{ error: "No resolved token map. Run `sorb-seed resolve` (Style Dictionary build)." },
|
|
530
|
+
404
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return c.json(resolved);
|
|
534
|
+
});
|
|
535
|
+
app.get("/artifacts", (c) => {
|
|
536
|
+
const idx = getArtifactIndex();
|
|
537
|
+
if (!idx) {
|
|
538
|
+
return c.json(
|
|
539
|
+
{ error: "No artifact index. Run `sorb-seed capture`." },
|
|
540
|
+
404
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return c.json(idx);
|
|
544
|
+
});
|
|
545
|
+
app.get("/artifact", (c) => {
|
|
546
|
+
const storyId = c.req.query("id");
|
|
547
|
+
if (!storyId) return c.json({ error: "Missing ?id=" }, 400);
|
|
548
|
+
const art = getArtifact(storyId);
|
|
549
|
+
if (!art) return c.json({ error: "Artifact not found for id: " + storyId }, 404);
|
|
550
|
+
return c.json(art);
|
|
551
|
+
});
|
|
552
|
+
app.get("/health", async (c) => {
|
|
553
|
+
return c.json({
|
|
554
|
+
ok: true,
|
|
555
|
+
namespace,
|
|
556
|
+
activePreviews: await store.countPreviews(),
|
|
557
|
+
verifications: await store.countVerifications()
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
app.get("/ready", async (c) => {
|
|
561
|
+
const checks = {};
|
|
562
|
+
try {
|
|
563
|
+
checks.store = await store.ping();
|
|
564
|
+
} catch (e) {
|
|
565
|
+
checks.store = false;
|
|
566
|
+
}
|
|
567
|
+
if (db) {
|
|
568
|
+
try {
|
|
569
|
+
checks.db = await db.ping();
|
|
570
|
+
} catch (e) {
|
|
571
|
+
checks.db = false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const ok = Object.values(checks).every(Boolean);
|
|
575
|
+
return c.json({ ok, checks }, ok ? 200 : 503);
|
|
576
|
+
});
|
|
577
|
+
return app;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// src/config.js
|
|
581
|
+
var DEFAULTS = Object.freeze({
|
|
582
|
+
PORT: 7777,
|
|
583
|
+
SORB_NAMESPACE: "sorb-local",
|
|
584
|
+
REDIS_URL: void 0,
|
|
585
|
+
DATABASE_URL: void 0,
|
|
586
|
+
CORS_ORIGINS: "*",
|
|
587
|
+
// open by default = free local mode
|
|
588
|
+
PREVIEW_TTL_MS: 864e5,
|
|
589
|
+
// 24h — preserves today's preview/verify lifetime
|
|
590
|
+
PRUNE_INTERVAL_MS: 36e5,
|
|
591
|
+
// 1h — preserves today's in-memory prune cadence
|
|
592
|
+
NODE_ENV: "development"
|
|
593
|
+
});
|
|
594
|
+
var intOr = (raw, fallback) => {
|
|
595
|
+
if (raw === void 0 || raw === null || String(raw).trim() === "") return fallback;
|
|
596
|
+
const n = Number.parseInt(String(raw), 10);
|
|
597
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
598
|
+
};
|
|
599
|
+
var strOrUndef = (raw) => {
|
|
600
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
601
|
+
const s = String(raw).trim();
|
|
602
|
+
return s === "" ? void 0 : s;
|
|
603
|
+
};
|
|
604
|
+
var parseCorsOrigins = (raw) => {
|
|
605
|
+
const s = strOrUndef(raw);
|
|
606
|
+
if (s === void 0) return "*";
|
|
607
|
+
const parts = s.split(",").map((o) => o.trim()).filter((o) => o !== "");
|
|
608
|
+
if (parts.length === 0 || parts.includes("*")) return "*";
|
|
609
|
+
return Array.from(new Set(parts));
|
|
610
|
+
};
|
|
611
|
+
var loadConfig = (env = process.env) => {
|
|
612
|
+
const redisUrl = strOrUndef(env.REDIS_URL);
|
|
613
|
+
const databaseUrl = strOrUndef(env.DATABASE_URL);
|
|
614
|
+
const config = {
|
|
615
|
+
port: intOr(env.PORT, DEFAULTS.PORT),
|
|
616
|
+
namespace: strOrUndef(env.SORB_NAMESPACE) ?? DEFAULTS.SORB_NAMESPACE,
|
|
617
|
+
// Presence of these URLs is the switch that activates each hosted backend.
|
|
618
|
+
redisUrl,
|
|
619
|
+
databaseUrl,
|
|
620
|
+
corsOrigins: parseCorsOrigins(env.CORS_ORIGINS),
|
|
621
|
+
previewTtlMs: intOr(env.PREVIEW_TTL_MS, DEFAULTS.PREVIEW_TTL_MS),
|
|
622
|
+
pruneIntervalMs: intOr(env.PRUNE_INTERVAL_MS, DEFAULTS.PRUNE_INTERVAL_MS),
|
|
623
|
+
nodeEnv: strOrUndef(env.NODE_ENV) ?? DEFAULTS.NODE_ENV,
|
|
624
|
+
// Convenience flags so the store factory + /ready don't re-derive presence.
|
|
625
|
+
redisEnabled: redisUrl !== void 0,
|
|
626
|
+
databaseEnabled: databaseUrl !== void 0,
|
|
627
|
+
hosted: redisUrl !== void 0 || databaseUrl !== void 0
|
|
628
|
+
};
|
|
629
|
+
return Object.freeze(config);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// src/store/memory.js
|
|
633
|
+
var createMemoryStore = (config) => {
|
|
634
|
+
const ttlMs = config.previewTtlMs;
|
|
635
|
+
const pruneIntervalMs = config.pruneIntervalMs;
|
|
636
|
+
const previews = /* @__PURE__ */ new Map();
|
|
637
|
+
const verifications = /* @__PURE__ */ new Map();
|
|
638
|
+
let latestVerifyId = null;
|
|
639
|
+
const isExpired = (entry, now) => now - entry.createdAt > ttlMs;
|
|
640
|
+
const pruneTimer = setInterval(() => {
|
|
641
|
+
const now = Date.now();
|
|
642
|
+
for (const [id, entry] of previews) {
|
|
643
|
+
if (isExpired(entry, now)) previews.delete(id);
|
|
644
|
+
}
|
|
645
|
+
for (const [id, entry] of verifications) {
|
|
646
|
+
if (isExpired(entry, now)) {
|
|
647
|
+
verifications.delete(id);
|
|
648
|
+
if (id === latestVerifyId) latestVerifyId = null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}, pruneIntervalMs);
|
|
652
|
+
if (typeof pruneTimer.unref === "function") pruneTimer.unref();
|
|
653
|
+
const putPreview = async (id, tokens) => {
|
|
654
|
+
previews.set(id, { tokens, createdAt: Date.now() });
|
|
655
|
+
};
|
|
656
|
+
const getPreview = async (id) => {
|
|
657
|
+
const entry = previews.get(id);
|
|
658
|
+
if (!entry) return null;
|
|
659
|
+
if (isExpired(entry, Date.now())) {
|
|
660
|
+
previews.delete(id);
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
return entry;
|
|
664
|
+
};
|
|
665
|
+
const hasPreview = async (id) => {
|
|
666
|
+
return await getPreview(id) !== null;
|
|
667
|
+
};
|
|
668
|
+
const updatePreview = async (id, tokens) => {
|
|
669
|
+
if (!await hasPreview(id)) return false;
|
|
670
|
+
previews.set(id, { tokens, createdAt: Date.now() });
|
|
671
|
+
return true;
|
|
672
|
+
};
|
|
673
|
+
const deletePreview = async (id) => {
|
|
674
|
+
previews.delete(id);
|
|
675
|
+
};
|
|
676
|
+
const countPreviews = async () => {
|
|
677
|
+
const now = Date.now();
|
|
678
|
+
let n = 0;
|
|
679
|
+
for (const entry of previews.values()) {
|
|
680
|
+
if (!isExpired(entry, now)) n++;
|
|
681
|
+
}
|
|
682
|
+
return n;
|
|
683
|
+
};
|
|
684
|
+
const putVerification = async (id, { storyId, bbox, meta }) => {
|
|
685
|
+
verifications.set(id, { storyId, bbox, meta, createdAt: Date.now() });
|
|
686
|
+
latestVerifyId = id;
|
|
687
|
+
};
|
|
688
|
+
const getVerification = async (id) => {
|
|
689
|
+
const entry = verifications.get(id);
|
|
690
|
+
if (!entry) return null;
|
|
691
|
+
if (isExpired(entry, Date.now())) {
|
|
692
|
+
verifications.delete(id);
|
|
693
|
+
if (id === latestVerifyId) latestVerifyId = null;
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
return entry;
|
|
697
|
+
};
|
|
698
|
+
const getLatestVerification = async () => {
|
|
699
|
+
if (!latestVerifyId) return null;
|
|
700
|
+
return getVerification(latestVerifyId);
|
|
701
|
+
};
|
|
702
|
+
const countVerifications = async () => {
|
|
703
|
+
const now = Date.now();
|
|
704
|
+
let n = 0;
|
|
705
|
+
for (const entry of verifications.values()) {
|
|
706
|
+
if (!isExpired(entry, now)) n++;
|
|
707
|
+
}
|
|
708
|
+
return n;
|
|
709
|
+
};
|
|
710
|
+
const ping = async () => true;
|
|
711
|
+
const close2 = async () => {
|
|
712
|
+
clearInterval(pruneTimer);
|
|
713
|
+
};
|
|
714
|
+
return {
|
|
715
|
+
putPreview,
|
|
716
|
+
getPreview,
|
|
717
|
+
hasPreview,
|
|
718
|
+
updatePreview,
|
|
719
|
+
deletePreview,
|
|
720
|
+
countPreviews,
|
|
721
|
+
putVerification,
|
|
722
|
+
getVerification,
|
|
723
|
+
getLatestVerification,
|
|
724
|
+
countVerifications,
|
|
725
|
+
ping,
|
|
726
|
+
close: close2
|
|
727
|
+
};
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// src/store/index.js
|
|
731
|
+
var createStore = async (config) => {
|
|
732
|
+
if (config.redisUrl) {
|
|
733
|
+
const { createRedisStore: createRedisStore2 } = await Promise.resolve().then(() => (init_redis(), redis_exports));
|
|
734
|
+
return createRedisStore2(config);
|
|
735
|
+
}
|
|
736
|
+
return createMemoryStore(config);
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// src/db/migrate.js
|
|
740
|
+
var import_promises = require("node:fs/promises");
|
|
741
|
+
var import_node_path = require("node:path");
|
|
742
|
+
var import_node_url = require("node:url");
|
|
743
|
+
var SELF_URL = __sorbModuleUrl;
|
|
744
|
+
var MIGRATIONS_DIR = (0, import_node_path.join)((0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(SELF_URL)), "migrations");
|
|
745
|
+
var SCHEMA_MIGRATIONS_DDL = `
|
|
746
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
747
|
+
filename text PRIMARY KEY,
|
|
748
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
749
|
+
)
|
|
750
|
+
`;
|
|
751
|
+
async function runMigrations(db, opts) {
|
|
752
|
+
if (!db) {
|
|
753
|
+
throw new Error("runMigrations: no DbHandle (DATABASE_URL not configured?)");
|
|
754
|
+
}
|
|
755
|
+
const dir = opts && opts.dir || MIGRATIONS_DIR;
|
|
756
|
+
await db.query(SCHEMA_MIGRATIONS_DDL);
|
|
757
|
+
let entries;
|
|
758
|
+
try {
|
|
759
|
+
entries = await (0, import_promises.readdir)(dir);
|
|
760
|
+
} catch (e) {
|
|
761
|
+
if (e && e.code === "ENOENT") return [];
|
|
762
|
+
throw e;
|
|
763
|
+
}
|
|
764
|
+
const files = entries.filter((f) => f.toLowerCase().endsWith(".sql")).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
765
|
+
const appliedRes = await db.query("SELECT filename FROM schema_migrations");
|
|
766
|
+
const already = new Set((appliedRes.rows || []).map((r) => r.filename));
|
|
767
|
+
const applied = [];
|
|
768
|
+
for (const file of files) {
|
|
769
|
+
if (already.has(file)) continue;
|
|
770
|
+
const sql = await (0, import_promises.readFile)((0, import_node_path.join)(dir, file), "utf8");
|
|
771
|
+
await db.tx(async (client) => {
|
|
772
|
+
await client.query(sql);
|
|
773
|
+
await client.query("INSERT INTO schema_migrations (filename) VALUES ($1)", [file]);
|
|
774
|
+
});
|
|
775
|
+
applied.push(file);
|
|
776
|
+
console.log(`[db] applied migration ${file}`);
|
|
777
|
+
}
|
|
778
|
+
return applied;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/db/index.js
|
|
782
|
+
async function createDb(config) {
|
|
783
|
+
const databaseUrl = config && config.databaseUrl;
|
|
784
|
+
if (!databaseUrl) {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
const pgModule = await import("pg");
|
|
788
|
+
const pg = pgModule.default || pgModule;
|
|
789
|
+
const { Pool } = pg;
|
|
790
|
+
const pool = new Pool({
|
|
791
|
+
connectionString: databaseUrl,
|
|
792
|
+
// Keep the pool conservative; the bridge is mostly Redis-bound. These can
|
|
793
|
+
// be tuned later via config if needed.
|
|
794
|
+
max: 10,
|
|
795
|
+
idleTimeoutMillis: 3e4,
|
|
796
|
+
connectionTimeoutMillis: 1e4
|
|
797
|
+
});
|
|
798
|
+
pool.on("error", (err) => {
|
|
799
|
+
console.error("[db] idle pool client error:", err && err.message ? err.message : err);
|
|
800
|
+
});
|
|
801
|
+
async function query(text, params) {
|
|
802
|
+
return pool.query(text, params);
|
|
803
|
+
}
|
|
804
|
+
async function getClient() {
|
|
805
|
+
return pool.connect();
|
|
806
|
+
}
|
|
807
|
+
async function tx(fn) {
|
|
808
|
+
const client = await pool.connect();
|
|
809
|
+
try {
|
|
810
|
+
await client.query("BEGIN");
|
|
811
|
+
const result = await fn(client);
|
|
812
|
+
await client.query("COMMIT");
|
|
813
|
+
return result;
|
|
814
|
+
} catch (e) {
|
|
815
|
+
try {
|
|
816
|
+
await client.query("ROLLBACK");
|
|
817
|
+
} catch (rollbackErr) {
|
|
818
|
+
console.error("[db] rollback failed:", rollbackErr && rollbackErr.message ? rollbackErr.message : rollbackErr);
|
|
819
|
+
}
|
|
820
|
+
throw e;
|
|
821
|
+
} finally {
|
|
822
|
+
client.release();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
async function ping() {
|
|
826
|
+
try {
|
|
827
|
+
const res = await pool.query("SELECT 1 AS ok");
|
|
828
|
+
return Boolean(res && res.rows && res.rows.length === 1);
|
|
829
|
+
} catch (e) {
|
|
830
|
+
console.error("[db] ping failed:", e && e.message ? e.message : e);
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
let closed = false;
|
|
835
|
+
async function close2() {
|
|
836
|
+
if (closed) return;
|
|
837
|
+
closed = true;
|
|
838
|
+
try {
|
|
839
|
+
await pool.end();
|
|
840
|
+
} catch (e) {
|
|
841
|
+
console.error("[db] close failed:", e && e.message ? e.message : e);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const handle = {
|
|
845
|
+
pool,
|
|
846
|
+
query,
|
|
847
|
+
getClient,
|
|
848
|
+
tx,
|
|
849
|
+
ping,
|
|
850
|
+
close: close2,
|
|
851
|
+
/** Apply pending SQL migrations in order. @returns {Promise<string[]>} */
|
|
852
|
+
runMigrations: () => runMigrations(handle)
|
|
853
|
+
};
|
|
854
|
+
return handle;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/watch.js
|
|
858
|
+
var import_chokidar = __toESM(require("chokidar"));
|
|
859
|
+
var import_fs = require("fs");
|
|
860
|
+
var import_path = require("path");
|
|
861
|
+
var import_picocolors = __toESM(require("picocolors"));
|
|
862
|
+
var watchSources = (paths, onChange) => {
|
|
863
|
+
const abs = (paths || []).map((p) => (0, import_path.resolve)(process.cwd(), p)).filter((p) => (0, import_fs.existsSync)(p));
|
|
864
|
+
if (!abs.length) {
|
|
865
|
+
console.warn(import_picocolors.default.yellow(" \u26A0 No token sources found to watch."));
|
|
866
|
+
return { stop: () => {
|
|
867
|
+
} };
|
|
868
|
+
}
|
|
869
|
+
const watcher = import_chokidar.default.watch(abs, {
|
|
870
|
+
ignoreInitial: true,
|
|
871
|
+
awaitWriteFinish: { stabilityThreshold: 100 }
|
|
872
|
+
});
|
|
873
|
+
watcher.on("change", (p) => {
|
|
874
|
+
console.log(import_picocolors.default.cyan(" \u2192 Token source changed") + import_picocolors.default.dim(` (${p})`));
|
|
875
|
+
onChange(p);
|
|
876
|
+
});
|
|
877
|
+
watcher.on("error", (err) => console.error(import_picocolors.default.red(" \u2717 Watcher error:"), err));
|
|
878
|
+
return { stop: () => watcher.close() };
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// src/transform.js
|
|
882
|
+
var import_child_process = require("child_process");
|
|
883
|
+
var import_fs2 = require("fs");
|
|
884
|
+
var import_path2 = require("path");
|
|
885
|
+
var import_picocolors2 = __toESM(require("picocolors"));
|
|
886
|
+
var runStyleDictionary = (configPath) => {
|
|
887
|
+
const abs = (0, import_path2.resolve)(process.cwd(), configPath);
|
|
888
|
+
if (!(0, import_fs2.existsSync)(abs)) {
|
|
889
|
+
console.warn(
|
|
890
|
+
import_picocolors2.default.yellow(` \u26A0 style-dictionary config not found at ${configPath}, skipping`)
|
|
891
|
+
);
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
console.log(import_picocolors2.default.dim(" \u2192 Running Style Dictionary..."));
|
|
896
|
+
(0, import_child_process.execSync)(`npx style-dictionary build --config ${abs}`, {
|
|
897
|
+
stdio: "inherit",
|
|
898
|
+
cwd: process.cwd()
|
|
899
|
+
});
|
|
900
|
+
console.log(import_picocolors2.default.green(" \u2713 Style Dictionary build complete"));
|
|
901
|
+
return true;
|
|
902
|
+
} catch (e) {
|
|
903
|
+
console.error(import_picocolors2.default.red(" \u2717 Style Dictionary build failed"));
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
// src/github.js
|
|
909
|
+
var import_picocolors3 = __toESM(require("picocolors"));
|
|
910
|
+
var openTokenPR = async (opts) => {
|
|
911
|
+
const base = `https://api.github.com/repos/${opts.owner}/${opts.repo}`;
|
|
912
|
+
const headers = {
|
|
913
|
+
Authorization: `Bearer ${opts.pat}`,
|
|
914
|
+
"Content-Type": "application/json",
|
|
915
|
+
Accept: "application/vnd.github+json"
|
|
916
|
+
};
|
|
917
|
+
const mainRef = await fetch(`${base}/git/ref/heads/main`, { headers }).then((r) => r.json());
|
|
918
|
+
const sha = mainRef.object.sha;
|
|
919
|
+
const branchName = `tokens/update-${Date.now()}`;
|
|
920
|
+
await fetch(`${base}/git/refs`, {
|
|
921
|
+
method: "POST",
|
|
922
|
+
headers,
|
|
923
|
+
body: JSON.stringify({
|
|
924
|
+
ref: `refs/heads/${branchName}`,
|
|
925
|
+
sha
|
|
926
|
+
})
|
|
927
|
+
});
|
|
928
|
+
const fileRes = await fetch(`${base}/contents/${opts.tokenPath}`, {
|
|
929
|
+
headers
|
|
930
|
+
});
|
|
931
|
+
const fileData = fileRes.ok ? await fileRes.json() : null;
|
|
932
|
+
await fetch(`${base}/contents/${opts.tokenPath}`, {
|
|
933
|
+
method: "PUT",
|
|
934
|
+
headers,
|
|
935
|
+
body: JSON.stringify({
|
|
936
|
+
message: opts.message,
|
|
937
|
+
content: Buffer.from(opts.content).toString("base64"),
|
|
938
|
+
branch: branchName,
|
|
939
|
+
...fileData?.sha ? { sha: fileData.sha } : {}
|
|
940
|
+
})
|
|
941
|
+
});
|
|
942
|
+
const pr = await fetch(`${base}/pulls`, {
|
|
943
|
+
method: "POST",
|
|
944
|
+
headers,
|
|
945
|
+
body: JSON.stringify({
|
|
946
|
+
title: opts.message,
|
|
947
|
+
head: branchName,
|
|
948
|
+
base: "main",
|
|
949
|
+
body: [
|
|
950
|
+
"> \u{1F3A8} Created by **Sorb**",
|
|
951
|
+
"",
|
|
952
|
+
"This PR was generated from the Sorb Figma plugin.",
|
|
953
|
+
"Review the token diff and merge to apply changes to the app."
|
|
954
|
+
].join("\n")
|
|
955
|
+
})
|
|
956
|
+
}).then((r) => r.json());
|
|
957
|
+
return pr.html_url;
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
// src/sentry.js
|
|
961
|
+
var Sentry = __toESM(require("@sentry/node"));
|
|
962
|
+
var enabled = false;
|
|
963
|
+
var initSentry = () => {
|
|
964
|
+
if (enabled) return true;
|
|
965
|
+
const dsn = process.env.SENTRY_DSN;
|
|
966
|
+
if (!dsn) return false;
|
|
967
|
+
try {
|
|
968
|
+
Sentry.init({
|
|
969
|
+
dsn,
|
|
970
|
+
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "production",
|
|
971
|
+
tracesSampleRate: 0
|
|
972
|
+
});
|
|
973
|
+
enabled = true;
|
|
974
|
+
} catch (e) {
|
|
975
|
+
enabled = false;
|
|
976
|
+
}
|
|
977
|
+
return enabled;
|
|
978
|
+
};
|
|
979
|
+
var captureError = (err, context) => {
|
|
980
|
+
if (!enabled) return;
|
|
981
|
+
try {
|
|
982
|
+
Sentry.captureException(err, context ? { extra: context } : void 0);
|
|
983
|
+
} catch (e) {
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
var flushSentry = async (ms) => {
|
|
987
|
+
if (!enabled) return;
|
|
988
|
+
try {
|
|
989
|
+
await Sentry.close(ms);
|
|
990
|
+
} catch (e) {
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// src/cli.js
|
|
995
|
+
var loadFileConfig = () => {
|
|
996
|
+
const configPath = (0, import_path3.resolve)(process.cwd(), "sorb.config.json");
|
|
997
|
+
if (!(0, import_fs3.existsSync)(configPath)) {
|
|
998
|
+
console.error(import_picocolors4.default.red("\n\u2717 No sorb.config.json found.\n"));
|
|
999
|
+
console.error(
|
|
1000
|
+
import_picocolors4.default.dim(" Run ") + import_picocolors4.default.cyan("sorb init") + import_picocolors4.default.dim(" to create one, or see the docs.\n")
|
|
1001
|
+
);
|
|
1002
|
+
process.exit(1);
|
|
1003
|
+
}
|
|
1004
|
+
return JSON.parse((0, import_fs3.readFileSync)(configPath, "utf-8"));
|
|
1005
|
+
};
|
|
1006
|
+
import_commander.program.name("sorb").description("Sorb design token bridge").version("1.2.0");
|
|
1007
|
+
import_commander.program.command("dev", { isDefault: true }).description("Start the local token bridge server").option("-p, --port <port>", "port to listen on", "7777").action(async (opts) => {
|
|
1008
|
+
const fileConfig = loadFileConfig();
|
|
1009
|
+
const envConfig = loadConfig();
|
|
1010
|
+
const config = {
|
|
1011
|
+
...envConfig,
|
|
1012
|
+
namespace: fileConfig.namespace ?? envConfig.namespace,
|
|
1013
|
+
port: fileConfig.port ?? parseInt(opts.port) ?? envConfig.port
|
|
1014
|
+
};
|
|
1015
|
+
const port = config.port;
|
|
1016
|
+
const sources = fileConfig.tokenSources || (fileConfig.tokenPath ? [fileConfig.tokenPath] : []);
|
|
1017
|
+
console.log(import_picocolors4.default.bold("\nSorb"));
|
|
1018
|
+
console.log(import_picocolors4.default.dim(" Namespace :") + ` ${config.namespace}`);
|
|
1019
|
+
console.log(import_picocolors4.default.dim(" Sources :") + ` ${sources.join(", ") || "(none)"}`);
|
|
1020
|
+
console.log(import_picocolors4.default.dim(" Port :") + ` ${port}`);
|
|
1021
|
+
const sorbDir = (0, import_path3.resolve)(process.cwd(), ".sorb");
|
|
1022
|
+
const resolvedPath = (0, import_path3.resolve)(sorbDir, "resolved.json");
|
|
1023
|
+
const indexPath = (0, import_path3.resolve)(sorbDir, "index.json");
|
|
1024
|
+
const readJson = (p) => {
|
|
1025
|
+
if (!(0, import_fs3.existsSync)(p)) return null;
|
|
1026
|
+
try {
|
|
1027
|
+
return JSON.parse((0, import_fs3.readFileSync)(p, "utf-8"));
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
const readResolvedArray = () => {
|
|
1033
|
+
const data = readJson(resolvedPath);
|
|
1034
|
+
return data && (Array.isArray(data) ? data : data.tokens);
|
|
1035
|
+
};
|
|
1036
|
+
const readResolved = () => readResolvedArray();
|
|
1037
|
+
const readIndex = () => readJson(indexPath);
|
|
1038
|
+
const buildTokens = () => {
|
|
1039
|
+
if (fileConfig.styleDictionaryConfig) runStyleDictionary(fileConfig.styleDictionaryConfig);
|
|
1040
|
+
};
|
|
1041
|
+
buildTokens();
|
|
1042
|
+
const { stop } = watchSources(sources, buildTokens);
|
|
1043
|
+
const legacyTokenAbs = fileConfig.tokenPath ? (0, import_path3.resolve)(process.cwd(), fileConfig.tokenPath) : null;
|
|
1044
|
+
const read = () => {
|
|
1045
|
+
if (legacyTokenAbs && (0, import_fs3.existsSync)(legacyTokenAbs)) {
|
|
1046
|
+
try {
|
|
1047
|
+
return JSON.parse((0, import_fs3.readFileSync)(legacyTokenAbs, "utf-8"));
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
return {};
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const arr = readResolvedArray();
|
|
1053
|
+
if (!arr) return {};
|
|
1054
|
+
const flat = {};
|
|
1055
|
+
for (const t of arr) if (t.cssVar) flat[t.cssVar.replace(/^--/, "")] = t.value;
|
|
1056
|
+
return flat;
|
|
1057
|
+
};
|
|
1058
|
+
const readArtifact = (storyId) => {
|
|
1059
|
+
const idx = readIndex();
|
|
1060
|
+
const entry = idx && idx.stories && idx.stories[storyId];
|
|
1061
|
+
if (!entry) return null;
|
|
1062
|
+
const artPath = (0, import_path3.resolve)(process.cwd(), entry.artifact);
|
|
1063
|
+
if (!artPath.startsWith(process.cwd() + "/")) return null;
|
|
1064
|
+
return readJson(artPath);
|
|
1065
|
+
};
|
|
1066
|
+
const store = await createStore(config);
|
|
1067
|
+
const db = await createDb(config);
|
|
1068
|
+
if (db && db.runMigrations) await db.runMigrations();
|
|
1069
|
+
const app = createServer({
|
|
1070
|
+
store,
|
|
1071
|
+
config,
|
|
1072
|
+
db,
|
|
1073
|
+
getLatestTokens: read,
|
|
1074
|
+
getResolvedTokens: readResolved,
|
|
1075
|
+
getArtifactIndex: readIndex,
|
|
1076
|
+
getArtifact: readArtifact
|
|
1077
|
+
});
|
|
1078
|
+
(0, import_node_server.serve)({ fetch: app.fetch, port }, () => {
|
|
1079
|
+
console.log(
|
|
1080
|
+
import_picocolors4.default.dim("\n Preview URL :") + import_picocolors4.default.cyan(` http://localhost:${port}/preview`)
|
|
1081
|
+
);
|
|
1082
|
+
console.log(
|
|
1083
|
+
import_picocolors4.default.dim(" Latest :") + import_picocolors4.default.cyan(` http://localhost:${port}/tokens/latest`)
|
|
1084
|
+
);
|
|
1085
|
+
console.log(
|
|
1086
|
+
import_picocolors4.default.dim(" Health :") + import_picocolors4.default.cyan(` http://localhost:${port}/health`)
|
|
1087
|
+
);
|
|
1088
|
+
console.log(import_picocolors4.default.dim("\n Watching for token file changes...\n"));
|
|
1089
|
+
});
|
|
1090
|
+
process.on("SIGINT", async () => {
|
|
1091
|
+
stop();
|
|
1092
|
+
try {
|
|
1093
|
+
await store.close();
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
}
|
|
1096
|
+
if (db) {
|
|
1097
|
+
try {
|
|
1098
|
+
await db.close();
|
|
1099
|
+
} catch (e) {
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
console.log(import_picocolors4.default.dim("\n Stopped.\n"));
|
|
1103
|
+
process.exit(0);
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
import_commander.program.command("serve").description("Run the hosted bridge server (config from env; no sorb.config.json)").action(async () => {
|
|
1107
|
+
initSentry();
|
|
1108
|
+
process.on("unhandledRejection", (reason) => {
|
|
1109
|
+
captureError(reason, { at: "unhandledRejection" });
|
|
1110
|
+
});
|
|
1111
|
+
process.on("uncaughtException", (err) => {
|
|
1112
|
+
captureError(err, { at: "uncaughtException" });
|
|
1113
|
+
});
|
|
1114
|
+
const config = loadConfig();
|
|
1115
|
+
const port = config.port;
|
|
1116
|
+
console.log(import_picocolors4.default.bold("\nSorb (hosted bridge)"));
|
|
1117
|
+
console.log(import_picocolors4.default.dim(" Namespace :") + ` ${config.namespace}`);
|
|
1118
|
+
console.log(import_picocolors4.default.dim(" Port :") + ` ${port}`);
|
|
1119
|
+
console.log(import_picocolors4.default.dim(" Redis :") + ` ${config.redisUrl ? "on" : "off (in-memory)"}`);
|
|
1120
|
+
console.log(import_picocolors4.default.dim(" Postgres :") + ` ${config.databaseUrl ? "on" : "off"}`);
|
|
1121
|
+
const store = await createStore(config);
|
|
1122
|
+
let db = null;
|
|
1123
|
+
try {
|
|
1124
|
+
db = await createDb(config);
|
|
1125
|
+
if (db && db.runMigrations) await db.runMigrations();
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
console.error(import_picocolors4.default.yellow(" \u26A0 Postgres init failed; continuing without DB: ") + e.message);
|
|
1128
|
+
db = null;
|
|
1129
|
+
}
|
|
1130
|
+
const app = createServer({
|
|
1131
|
+
store,
|
|
1132
|
+
config,
|
|
1133
|
+
db,
|
|
1134
|
+
getLatestTokens: () => ({}),
|
|
1135
|
+
getResolvedTokens: () => null,
|
|
1136
|
+
getArtifactIndex: () => null,
|
|
1137
|
+
getArtifact: () => null,
|
|
1138
|
+
// Route server-side DB-error / bookkeeping catches to Sentry. NO-OP when
|
|
1139
|
+
// SENTRY_DSN is unset (captureError gates on the enabled flag).
|
|
1140
|
+
onError: captureError
|
|
1141
|
+
});
|
|
1142
|
+
(0, import_node_server.serve)({ fetch: app.fetch, port, hostname: "0.0.0.0" }, () => {
|
|
1143
|
+
console.log(import_picocolors4.default.dim(`
|
|
1144
|
+
Listening on 0.0.0.0:${port} \xB7 /health /ready
|
|
1145
|
+
`));
|
|
1146
|
+
});
|
|
1147
|
+
const shutdown = async () => {
|
|
1148
|
+
try {
|
|
1149
|
+
await store.close();
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
}
|
|
1152
|
+
if (db) {
|
|
1153
|
+
try {
|
|
1154
|
+
await db.close();
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
try {
|
|
1159
|
+
await flushSentry(2e3);
|
|
1160
|
+
} catch (e) {
|
|
1161
|
+
}
|
|
1162
|
+
process.exit(0);
|
|
1163
|
+
};
|
|
1164
|
+
process.on("SIGINT", shutdown);
|
|
1165
|
+
process.on("SIGTERM", shutdown);
|
|
1166
|
+
});
|
|
1167
|
+
import_commander.program.command("init").description("Create a sorb.config.json in the current directory").action(() => {
|
|
1168
|
+
const configPath = (0, import_path3.resolve)(process.cwd(), "sorb.config.json");
|
|
1169
|
+
if ((0, import_fs3.existsSync)(configPath)) {
|
|
1170
|
+
console.log(import_picocolors4.default.yellow(" sorb.config.json already exists"));
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const defaults = {
|
|
1174
|
+
namespace: "my-app",
|
|
1175
|
+
tokenSources: [
|
|
1176
|
+
"tokens/primitive.json",
|
|
1177
|
+
"tokens/semantic.json",
|
|
1178
|
+
"tokens/component.json"
|
|
1179
|
+
],
|
|
1180
|
+
styleDictionaryConfig: "sd.config.js",
|
|
1181
|
+
port: 7777
|
|
1182
|
+
};
|
|
1183
|
+
(0, import_fs3.writeFileSync)(configPath, JSON.stringify(defaults, null, 2) + "\n");
|
|
1184
|
+
console.log(import_picocolors4.default.green(" \u2713 Created sorb.config.json"));
|
|
1185
|
+
});
|
|
1186
|
+
import_commander.program.command("commit").description("Open a GitHub PR with the current token file").requiredOption("--owner <owner>", "GitHub org or user").requiredOption("--repo <repo>", "GitHub repo name").requiredOption("--pat <pat>", "GitHub personal access token").option("--message <message>", "PR / commit title", "Update design tokens").action(async (opts) => {
|
|
1187
|
+
const config = loadFileConfig();
|
|
1188
|
+
const content = (0, import_fs3.readFileSync)(
|
|
1189
|
+
(0, import_path3.resolve)(process.cwd(), config.tokenPath),
|
|
1190
|
+
"utf-8"
|
|
1191
|
+
);
|
|
1192
|
+
console.log(import_picocolors4.default.dim("\n Opening PR..."));
|
|
1193
|
+
try {
|
|
1194
|
+
const url = await openTokenPR({
|
|
1195
|
+
owner: opts.owner,
|
|
1196
|
+
repo: opts.repo,
|
|
1197
|
+
tokenPath: config.tokenPath,
|
|
1198
|
+
content,
|
|
1199
|
+
message: opts.message,
|
|
1200
|
+
pat: opts.pat
|
|
1201
|
+
});
|
|
1202
|
+
console.log(import_picocolors4.default.green(` \u2713 PR opened: `) + import_picocolors4.default.cyan(url) + "\n");
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
console.error(import_picocolors4.default.red(" \u2717 Failed to open PR:"), err);
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
import_commander.program.parse();
|
|
1209
|
+
//# sourceMappingURL=cli.js.map
|