loopwind 0.25.6 → 0.25.7
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/app/.astro/types.d.ts +1 -0
- package/app/dist/_astro/callback.Ci5gaEfJ.css +1 -0
- package/app/dist/auth/callback/index.html +81 -0
- package/app/dist/device/index.html +70 -0
- package/app/dist/index.html +327 -0
- package/app/package-lock.json +9239 -0
- package/app/package.json +23 -0
- package/app/wrangler.toml +8 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +60 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +15 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/publish.d.ts +10 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +155 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/templates.d.ts +5 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +60 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/unpublish.d.ts +5 -0
- package/dist/commands/unpublish.d.ts.map +1 -0
- package/dist/commands/unpublish.js +54 -0
- package/dist/commands/unpublish.js.map +1 -0
- package/dist/commands/whoami.d.ts +5 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/lib/api.d.ts +92 -0
- package/dist/lib/api.d.ts.map +1 -0
- package/dist/lib/api.js +149 -0
- package/dist/lib/api.js.map +1 -0
- package/dist/lib/auth.d.ts +41 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +89 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/bundler.d.ts +18 -0
- package/dist/lib/bundler.d.ts.map +1 -0
- package/dist/lib/bundler.js +105 -0
- package/dist/lib/bundler.js.map +1 -0
- package/dist/lib/helpers.d.ts +35 -2
- package/dist/lib/helpers.d.ts.map +1 -1
- package/dist/lib/helpers.js +91 -13
- package/dist/lib/helpers.js.map +1 -1
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +9 -0
- package/dist/lib/utils.js.map +1 -1
- package/dist/sdk/edge.d.ts +65 -0
- package/dist/sdk/edge.d.ts.map +1 -0
- package/dist/sdk/edge.js +329 -0
- package/dist/sdk/edge.js.map +1 -0
- package/dist/sdk/errors.d.ts +64 -0
- package/dist/sdk/errors.d.ts.map +1 -0
- package/dist/sdk/errors.js +94 -0
- package/dist/sdk/errors.js.map +1 -0
- package/dist/sdk/index.d.ts +29 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +30 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/render.d.ts +52 -0
- package/dist/sdk/render.d.ts.map +1 -0
- package/dist/sdk/render.js +432 -0
- package/dist/sdk/render.js.map +1 -0
- package/dist/sdk/types.d.ts +185 -0
- package/dist/sdk/types.d.ts.map +1 -0
- package/dist/sdk/types.js +5 -0
- package/dist/sdk/types.js.map +1 -0
- package/dist/types/template.d.ts +18 -0
- package/dist/types/template.d.ts.map +1 -1
- package/package.json +26 -4
- package/plans/PLATFORM.md +1637 -237
- package/plans/PLATFORM_IMPLEMENTATION.md +1347 -530
- package/plans/SDK.md +797 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite +0 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-shm +0 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-wal +0 -0
- package/platform/migrations/0001_initial.sql +90 -0
- package/platform/package-lock.json +3104 -0
- package/platform/package.json +30 -0
- package/platform/wrangler.toml +43 -0
- package/tests-sdk/createRenderer.test.ts +251 -0
- package/tests-sdk/errors.test.ts +230 -0
- package/tests-sdk/render.test.ts +241 -0
- package/tests-sdk/tw.test.ts +277 -0
package/plans/PLATFORM.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# Loopwind API Service Design
|
|
2
2
|
|
|
3
|
+
> **Related docs**:
|
|
4
|
+
> - [SDK.md](./SDK.md) - Local rendering library (no platform needed)
|
|
5
|
+
|
|
3
6
|
## Overview
|
|
4
7
|
|
|
5
8
|
This document outlines the architecture for a cloud-based Loopwind API service that enables:
|
|
6
9
|
- **Publishing templates** via `loopwind publish template-name`
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
+
- **Simple render URLs** - each template gets a unique URL like `api.loopwind.dev/r/x7k9m2p4?title=Hello`
|
|
11
|
+
- **User accounts** at loopwind.dev with organization-based isolation
|
|
12
|
+
- **No SDK required** - just use the URL in `<img>` tags or fetch directly
|
|
10
13
|
|
|
11
14
|
---
|
|
12
15
|
|
|
@@ -17,7 +20,7 @@ This document outlines the architecture for a cloud-based Loopwind API service t
|
|
|
17
20
|
```
|
|
18
21
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
19
22
|
│ loopwind CLI │
|
|
20
|
-
│
|
|
23
|
+
│ loopwind publish │
|
|
21
24
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
22
25
|
│
|
|
23
26
|
▼
|
|
@@ -112,25 +115,56 @@ DELETE /api/keys/:id
|
|
|
112
115
|
### CLI Authentication Flow
|
|
113
116
|
|
|
114
117
|
```bash
|
|
115
|
-
# Login via browser (OAuth-style device flow)
|
|
116
118
|
$ loopwind login
|
|
117
|
-
Opening browser for authentication...
|
|
118
|
-
Waiting for confirmation...
|
|
119
|
-
✓ Logged in as user@example.com
|
|
120
119
|
|
|
121
|
-
|
|
120
|
+
Opening browser...
|
|
121
|
+
https://loopwind.dev/cli/login?session=abc123
|
|
122
|
+
|
|
123
|
+
Waiting for authorization...
|
|
124
|
+
|
|
125
|
+
✓ Logged in as tommy (via GitHub)
|
|
126
|
+
✓ Organization: my-org
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Flow:**
|
|
130
|
+
1. CLI generates random session ID, opens browser
|
|
131
|
+
2. User clicks "Continue with GitHub"
|
|
132
|
+
3. GitHub OAuth → callback to loopwind.dev
|
|
133
|
+
4. loopwind.dev creates/finds user, links to session
|
|
134
|
+
5. CLI polls, gets token when authorized
|
|
135
|
+
|
|
136
|
+
**Stored credentials:**
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
// ~/.loopwind/credentials.json
|
|
122
140
|
{
|
|
123
|
-
"token": "
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
|
|
141
|
+
"token": "eyJhbG...",
|
|
142
|
+
"expiresAt": "2025-02-06T12:00:00Z",
|
|
143
|
+
"user": {
|
|
144
|
+
"id": "usr_abc123",
|
|
145
|
+
"email": "tommy@example.com",
|
|
146
|
+
"username": "tommy"
|
|
147
|
+
},
|
|
148
|
+
"organization": {
|
|
149
|
+
"id": "org_xyz",
|
|
150
|
+
"name": "my-org"
|
|
151
|
+
}
|
|
127
152
|
}
|
|
153
|
+
```
|
|
128
154
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
155
|
+
**For CI/CD (no browser):**
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
$ loopwind login --token
|
|
159
|
+
|
|
160
|
+
Paste your API key from loopwind.dev/settings/tokens:
|
|
161
|
+
> lw_xxxxxxxxxxxxx
|
|
162
|
+
|
|
163
|
+
✓ Authenticated
|
|
132
164
|
```
|
|
133
165
|
|
|
166
|
+
> **Future:** Add Google OAuth as alternative to GitHub.
|
|
167
|
+
|
|
134
168
|
---
|
|
135
169
|
|
|
136
170
|
## Template Publishing
|
|
@@ -157,11 +191,104 @@ Options:
|
|
|
157
191
|
└─────────────┘ └──────────────┘ └────────────────┘ └─────────────┘
|
|
158
192
|
│ │ │ │
|
|
159
193
|
▼ ▼ ▼ ▼
|
|
160
|
-
Check meta
|
|
161
|
-
Validate props Include assets
|
|
162
|
-
|
|
194
|
+
Check meta Bundle template POST to org DO Generate render
|
|
195
|
+
Validate props Include assets Store config token (random ID)
|
|
196
|
+
Load config Include fonts Store files Return render URL
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**What gets uploaded:**
|
|
200
|
+
- `template.tsx` - source code (entry point)
|
|
201
|
+
- `template.js` - compiled bundle (includes all imports)
|
|
202
|
+
- `config.json` - loopwind.json config (colors, fonts, tokens)
|
|
203
|
+
- `assets/*` - images, fonts from all imported templates
|
|
204
|
+
|
|
205
|
+
### Template Dependencies
|
|
206
|
+
|
|
207
|
+
Templates can import other templates (layouts, components). Everything gets bundled:
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
// .loopwind/og-card/template.tsx
|
|
211
|
+
import { Layout } from '../shared/layout';
|
|
212
|
+
import { Card } from '../components/card';
|
|
213
|
+
import { Avatar } from '../components/avatar';
|
|
214
|
+
|
|
215
|
+
export default function OGCard({ title, author, tw }) {
|
|
216
|
+
return (
|
|
217
|
+
<Layout tw={tw}>
|
|
218
|
+
<Card tw={tw}>
|
|
219
|
+
<Avatar src={author.avatar} tw={tw} />
|
|
220
|
+
<h1 style={tw("text-4xl")}>{title}</h1>
|
|
221
|
+
</Card>
|
|
222
|
+
</Layout>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Publish bundles everything:**
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
$ loopwind publish og-card
|
|
231
|
+
|
|
232
|
+
Bundling...
|
|
233
|
+
✓ og-card/template.tsx (entry)
|
|
234
|
+
✓ shared/layout.tsx (imported)
|
|
235
|
+
✓ components/card.tsx (imported)
|
|
236
|
+
✓ components/avatar.tsx (imported)
|
|
237
|
+
|
|
238
|
+
✓ Published og-card v1.0.0
|
|
239
|
+
|
|
240
|
+
Included:
|
|
241
|
+
- template.js (4.2 KB) ← single bundle with all imports
|
|
242
|
+
- config: 12 colors, 2 fonts
|
|
243
|
+
- assets: logo.png, avatar-placeholder.png
|
|
163
244
|
```
|
|
164
245
|
|
|
246
|
+
The compiled `template.js` is self-contained - no external imports needed at runtime.
|
|
247
|
+
|
|
248
|
+
### Images in Published Templates
|
|
249
|
+
|
|
250
|
+
The `image()` helper works with published templates:
|
|
251
|
+
|
|
252
|
+
**1. Template assets (bundled)**
|
|
253
|
+
|
|
254
|
+
Assets in the template directory are uploaded with the template:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
// Template uses local asset
|
|
258
|
+
<img src={image('logo.svg')} />
|
|
259
|
+
<img src={image('assets/icon.png')} />
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
These are stored in `template_files` and served from the platform.
|
|
263
|
+
|
|
264
|
+
**2. External URLs (passed as props)**
|
|
265
|
+
|
|
266
|
+
Pass image URLs as query params:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
GET /r/x7k9m2p4?title=Hello&background=https://example.com/bg.jpg
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
// Template receives URL in props
|
|
274
|
+
export default function Card({ title, background, tw, image }) {
|
|
275
|
+
return (
|
|
276
|
+
<div style={tw('w-full h-full')}>
|
|
277
|
+
<img src={image('background')} style={tw('absolute inset-0')} />
|
|
278
|
+
<h1 style={tw('text-6xl')}>{title}</h1>
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**3. What works where:**
|
|
285
|
+
|
|
286
|
+
| Image Source | CLI (local) | Platform (published) |
|
|
287
|
+
|--------------|-------------|----------------------|
|
|
288
|
+
| Template assets (`image('logo.svg')`) | ✅ | ✅ (bundled) |
|
|
289
|
+
| External URLs (`image('background')` → URL prop) | ✅ | ✅ |
|
|
290
|
+
| Local file paths (`./images/bg.jpg`) | ✅ | ❌ (use URLs) |
|
|
291
|
+
|
|
165
292
|
### Publish API Endpoints
|
|
166
293
|
|
|
167
294
|
```
|
|
@@ -187,11 +314,10 @@ Content-Type: multipart/form-data
|
|
|
187
314
|
}
|
|
188
315
|
|
|
189
316
|
→ 201 {
|
|
190
|
-
"id": "
|
|
317
|
+
"id": "tpl_x7k9m2p4",
|
|
191
318
|
"name": "og-card",
|
|
192
319
|
"version": "1.0.0",
|
|
193
|
-
"
|
|
194
|
-
"url": "https://loopwind.dev/username/og-card",
|
|
320
|
+
"renderUrl": "https://api.loopwind.dev/r/x7k9m2p4",
|
|
195
321
|
"publishedAt": "2025-01-05T..."
|
|
196
322
|
}
|
|
197
323
|
```
|
|
@@ -200,17 +326,11 @@ Content-Type: multipart/form-data
|
|
|
200
326
|
|
|
201
327
|
```typescript
|
|
202
328
|
interface PublishedTemplate {
|
|
203
|
-
id: string; //
|
|
329
|
+
id: string; // tpl_x7k9m2p4 (random, used in render URL)
|
|
204
330
|
name: string; // og-card
|
|
205
|
-
slug: string; // username/og-card
|
|
206
331
|
version: string; // 1.0.0
|
|
207
332
|
description: string;
|
|
208
|
-
|
|
209
|
-
author: {
|
|
210
|
-
id: string;
|
|
211
|
-
username: string;
|
|
212
|
-
avatar?: string;
|
|
213
|
-
};
|
|
333
|
+
renderUrl: string; // https://api.loopwind.dev/r/x7k9m2p4
|
|
214
334
|
|
|
215
335
|
meta: {
|
|
216
336
|
type: "image" | "video";
|
|
@@ -219,16 +339,8 @@ interface PublishedTemplate {
|
|
|
219
339
|
props: Record<string, string>;
|
|
220
340
|
};
|
|
221
341
|
|
|
222
|
-
files: {
|
|
223
|
-
path: string;
|
|
224
|
-
url: string; // CDN URL
|
|
225
|
-
checksum: string;
|
|
226
|
-
}[];
|
|
227
|
-
|
|
228
342
|
stats: {
|
|
229
|
-
downloads: number;
|
|
230
343
|
renders: number;
|
|
231
|
-
stars: number;
|
|
232
344
|
};
|
|
233
345
|
|
|
234
346
|
private: boolean;
|
|
@@ -244,120 +356,1138 @@ interface PublishedTemplate {
|
|
|
244
356
|
|
|
245
357
|
## Rendering API
|
|
246
358
|
|
|
247
|
-
|
|
359
|
+
After publishing, each template gets a unique render URL. Two ways to render:
|
|
248
360
|
|
|
249
|
-
|
|
361
|
+
1. **GET with query params** - Simple, cacheable, works in `<img>` tags
|
|
362
|
+
2. **POST with JSON body** - Complex props, arrays, nested objects
|
|
250
363
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
364
|
+
Each template gets a random ID (e.g., `x7k9m2p4`) that's used in the URL. This:
|
|
365
|
+
- Prevents enumeration of organizations/templates
|
|
366
|
+
- Doesn't expose organization or template names
|
|
367
|
+
- Can be revoked/rotated if leaked
|
|
255
368
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
"scale": 2 // Optional, for retina (default: 1)
|
|
265
|
-
}
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
### GET - URL-Based Rendering
|
|
372
|
+
|
|
373
|
+
Best for simple props, OG images, anywhere you need a URL.
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
GET https://api.loopwind.dev/r/x7k9m2p4?title=Hello%20World&subtitle=Welcome
|
|
266
377
|
|
|
267
378
|
→ 200 (binary image data)
|
|
268
379
|
Content-Type: image/png
|
|
380
|
+
Cache-Control: public, max-age=86400
|
|
269
381
|
X-Render-Duration: 234ms
|
|
270
|
-
X-Render-Id: rnd_xyz789
|
|
271
382
|
```
|
|
272
383
|
|
|
273
|
-
|
|
384
|
+
**Query Parameters:**
|
|
385
|
+
- Template props: `?title=Hello&subtitle=World`
|
|
386
|
+
- Format: `?format=png` (default) / `webp` / `jpg` / `svg`
|
|
387
|
+
- Size override: `?width=800&height=400`
|
|
388
|
+
- Version: `?v=1.0.0` (pin to specific version, default: latest)
|
|
274
389
|
|
|
275
|
-
|
|
390
|
+
**Usage:**
|
|
276
391
|
|
|
392
|
+
```html
|
|
393
|
+
<!-- Direct in HTML -->
|
|
394
|
+
<img src="https://api.loopwind.dev/r/x7k9m2p4?title=Hello%20World" />
|
|
395
|
+
|
|
396
|
+
<!-- OG meta tag -->
|
|
397
|
+
<meta property="og:image" content="https://api.loopwind.dev/r/x7k9m2p4?title=My%20Blog%20Post" />
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
### Dynamic OG Images
|
|
403
|
+
|
|
404
|
+
The main use case: generate unique OG images for each page using just an `<img>` tag or meta tag.
|
|
405
|
+
|
|
406
|
+
**Template with dynamic title + featured image:**
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
// .loopwind/blog-og/template.tsx
|
|
410
|
+
export const meta = {
|
|
411
|
+
name: "blog-og",
|
|
412
|
+
type: "image",
|
|
413
|
+
size: { width: 1200, height: 630 },
|
|
414
|
+
props: {
|
|
415
|
+
title: "string",
|
|
416
|
+
image: "string?", // Optional featured image URL
|
|
417
|
+
author: "string?",
|
|
418
|
+
date: "string?",
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
export default function BlogOG({ title, image, author, date, tw }) {
|
|
423
|
+
return (
|
|
424
|
+
<div style={tw("w-full h-full flex bg-white")}>
|
|
425
|
+
{/* Featured image (left half) */}
|
|
426
|
+
{image && (
|
|
427
|
+
<img
|
|
428
|
+
src={image}
|
|
429
|
+
style={tw("w-1/2 h-full object-cover")}
|
|
430
|
+
/>
|
|
431
|
+
)}
|
|
432
|
+
|
|
433
|
+
{/* Content (right half or full if no image) */}
|
|
434
|
+
<div style={tw(`${image ? 'w-1/2' : 'w-full'} h-full flex flex-col justify-center p-12`)}>
|
|
435
|
+
<h1 style={tw("text-5xl font-bold text-gray-900 leading-tight")}>
|
|
436
|
+
{title}
|
|
437
|
+
</h1>
|
|
438
|
+
{author && (
|
|
439
|
+
<p style={tw("text-xl text-gray-600 mt-4")}>
|
|
440
|
+
{author} {date && `• ${date}`}
|
|
441
|
+
</p>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Use in HTML (static site, any framework):**
|
|
450
|
+
|
|
451
|
+
```html
|
|
452
|
+
<!-- Pass all props as URL params -->
|
|
453
|
+
<meta
|
|
454
|
+
property="og:image"
|
|
455
|
+
content="https://api.loopwind.dev/r/x7k9m2p4?title=How%20to%20Build%20APIs&image=https://example.com/featured.jpg&author=Jane%20Doe"
|
|
456
|
+
/>
|
|
457
|
+
|
|
458
|
+
<!-- Or just an img tag -->
|
|
459
|
+
<img src="https://api.loopwind.dev/r/x7k9m2p4?title=My%20Post&image=https://cdn.example.com/photo.jpg" />
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**Use in Next.js:**
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
// app/blog/[slug]/page.tsx
|
|
466
|
+
export async function generateMetadata({ params }) {
|
|
467
|
+
const post = await getPost(params.slug);
|
|
468
|
+
|
|
469
|
+
// Build OG image URL with dynamic props
|
|
470
|
+
const ogUrl = new URL('https://api.loopwind.dev/r/x7k9m2p4');
|
|
471
|
+
ogUrl.searchParams.set('title', post.title);
|
|
472
|
+
ogUrl.searchParams.set('author', post.author.name);
|
|
473
|
+
if (post.featuredImage) {
|
|
474
|
+
ogUrl.searchParams.set('image', post.featuredImage);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
openGraph: {
|
|
479
|
+
images: [ogUrl.toString()],
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
277
483
|
```
|
|
278
|
-
POST /api/render/async
|
|
279
|
-
Authorization: Bearer <token>
|
|
280
|
-
Content-Type: application/json
|
|
281
484
|
|
|
485
|
+
**Use in Astro:**
|
|
486
|
+
|
|
487
|
+
```astro
|
|
488
|
+
---
|
|
489
|
+
// src/pages/blog/[slug].astro
|
|
490
|
+
const { post } = Astro.props;
|
|
491
|
+
|
|
492
|
+
const ogParams = new URLSearchParams({
|
|
493
|
+
title: post.title,
|
|
494
|
+
author: post.author,
|
|
495
|
+
image: post.featuredImage,
|
|
496
|
+
date: post.date,
|
|
497
|
+
});
|
|
498
|
+
const ogImage = `https://api.loopwind.dev/r/x7k9m2p4?${ogParams}`;
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
<head>
|
|
502
|
+
<meta property="og:image" content={ogImage} />
|
|
503
|
+
</head>
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
**Use in Hugo/Jekyll (static HTML):**
|
|
507
|
+
|
|
508
|
+
```html
|
|
509
|
+
<!-- Hugo -->
|
|
510
|
+
<meta property="og:image" content="https://api.loopwind.dev/r/x7k9m2p4?title={{ .Title | urlquery }}&image={{ .Params.featured_image | urlquery }}" />
|
|
511
|
+
|
|
512
|
+
<!-- Jekyll -->
|
|
513
|
+
<meta property="og:image" content="https://api.loopwind.dev/r/x7k9m2p4?title={{ page.title | url_encode }}&image={{ page.image | url_encode }}" />
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
**Key points:**
|
|
517
|
+
- Images are passed as URL props (external URLs)
|
|
518
|
+
- Everything URL-encoded automatically by frameworks
|
|
519
|
+
- Cached in R2 - same URL = instant response
|
|
520
|
+
- No server-side code needed - works with static sites
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
### Allowed Hosts (Security)
|
|
525
|
+
|
|
526
|
+
To prevent SSRF attacks and abuse, external image URLs must match allowed hosts configured by the organization.
|
|
527
|
+
|
|
528
|
+
**Dashboard UI (Settings → Allowed Hosts):**
|
|
529
|
+
|
|
530
|
+
```
|
|
531
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
532
|
+
│ Settings › Allowed Hosts │
|
|
533
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
534
|
+
│ │
|
|
535
|
+
│ External images in your templates can only be loaded from │
|
|
536
|
+
│ these domains. Add your CDN, CMS, or image hosting domains. │
|
|
537
|
+
│ │
|
|
538
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
539
|
+
│ │ cdn.mysite.com [×] │ │
|
|
540
|
+
│ │ images.unsplash.com [×] │ │
|
|
541
|
+
│ │ *.amazonaws.com [×] │ │
|
|
542
|
+
│ │ res.cloudinary.com [×] │ │
|
|
543
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
544
|
+
│ │
|
|
545
|
+
│ ┌─────────────────────────────────────────┐ ┌─────────────┐ │
|
|
546
|
+
│ │ Add domain (e.g. cdn.example.com) │ │ + Add │ │
|
|
547
|
+
│ └─────────────────────────────────────────┘ └─────────────┘ │
|
|
548
|
+
│ │
|
|
549
|
+
│ 💡 Use wildcards for subdomains: *.example.com │
|
|
550
|
+
│ │
|
|
551
|
+
│ ───────────────────────────────────────────────────────────── │
|
|
552
|
+
│ │
|
|
553
|
+
│ Default hosts (always allowed): │
|
|
554
|
+
│ • images.unsplash.com │
|
|
555
|
+
│ • cdn.sanity.io │
|
|
556
|
+
│ • res.cloudinary.com │
|
|
557
|
+
│ • *.supabase.co │
|
|
558
|
+
│ • *.githubusercontent.com │
|
|
559
|
+
│ │
|
|
560
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**API endpoint:**
|
|
564
|
+
|
|
565
|
+
```
|
|
566
|
+
GET /api/organizations/:org_id/allowed-hosts
|
|
567
|
+
→ 200 { "hosts": ["cdn.mysite.com", "*.amazonaws.com"] }
|
|
568
|
+
|
|
569
|
+
POST /api/organizations/:org_id/allowed-hosts
|
|
570
|
+
{ "host": "cdn.newsite.com" }
|
|
571
|
+
→ 201 { "hosts": [...] }
|
|
572
|
+
|
|
573
|
+
DELETE /api/organizations/:org_id/allowed-hosts/:host
|
|
574
|
+
→ 204
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**How it works:**
|
|
578
|
+
|
|
579
|
+
```
|
|
580
|
+
GET /r/x7k9m2p4?title=Hello&image=https://cdn.example.com/photo.jpg
|
|
581
|
+
│
|
|
582
|
+
▼
|
|
583
|
+
┌─────────────────────┐
|
|
584
|
+
│ Validate host │
|
|
585
|
+
│ │
|
|
586
|
+
│ cdn.example.com │
|
|
587
|
+
│ in allowedHosts? │
|
|
588
|
+
└──────────┬──────────┘
|
|
589
|
+
│
|
|
590
|
+
┌───────────────┴───────────────┐
|
|
591
|
+
│ │
|
|
592
|
+
▼ ▼
|
|
593
|
+
┌──────────┐ ┌──────────┐
|
|
594
|
+
│ Yes │ │ No │
|
|
595
|
+
│ Fetch │ │ Error │
|
|
596
|
+
│ image │ │ 403 │
|
|
597
|
+
└──────────┘ └──────────┘
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
**Blocked request:**
|
|
601
|
+
|
|
602
|
+
```
|
|
603
|
+
GET /r/x7k9m2p4?image=https://evil.com/malware.jpg
|
|
604
|
+
|
|
605
|
+
→ 403 {
|
|
606
|
+
"error": "host_not_allowed",
|
|
607
|
+
"message": "Host 'evil.com' is not in allowed hosts list",
|
|
608
|
+
"allowedHosts": ["cdn.example.com", "images.unsplash.com"]
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
**Wildcard patterns:**
|
|
613
|
+
|
|
614
|
+
| Pattern | Matches |
|
|
615
|
+
|---------|---------|
|
|
616
|
+
| `example.com` | Only `example.com` |
|
|
617
|
+
| `*.example.com` | `cdn.example.com`, `images.example.com` |
|
|
618
|
+
| `*.amazonaws.com` | `s3.amazonaws.com`, `my-bucket.s3.amazonaws.com` |
|
|
619
|
+
|
|
620
|
+
**Default allowed hosts (all organizations):**
|
|
621
|
+
|
|
622
|
+
```
|
|
623
|
+
- images.unsplash.com
|
|
624
|
+
- cdn.sanity.io
|
|
625
|
+
- res.cloudinary.com
|
|
626
|
+
- *.supabase.co
|
|
627
|
+
- *.githubusercontent.com
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
**Private/internal URLs always blocked:**
|
|
631
|
+
|
|
632
|
+
```
|
|
633
|
+
- localhost, 127.0.0.1, ::1
|
|
634
|
+
- 10.*, 172.16-31.*, 192.168.*
|
|
635
|
+
- *.internal, *.local
|
|
636
|
+
- file://, data:// schemes
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
**Image size limits:**
|
|
640
|
+
|
|
641
|
+
When fetching external images, the platform enforces limits to prevent abuse:
|
|
642
|
+
|
|
643
|
+
| Limit | Value |
|
|
644
|
+
|-------|-------|
|
|
645
|
+
| Max file size | 10 MB |
|
|
646
|
+
| Max dimensions | 4096 x 4096 px |
|
|
647
|
+
| Fetch timeout | 10 seconds |
|
|
648
|
+
| Allowed schemes | `https://` only |
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
### Image Transformations (Cloudflare Images)
|
|
653
|
+
|
|
654
|
+
External images passed as props can be automatically optimized and transformed using [Cloudflare Images](https://developers.cloudflare.com/images/). This provides:
|
|
655
|
+
|
|
656
|
+
- **On-the-fly resizing** - Crop/resize images to fit your template
|
|
657
|
+
- **Format conversion** - Automatic WebP/AVIF for smaller files
|
|
658
|
+
- **Global CDN** - Images served from Cloudflare's edge
|
|
659
|
+
- **Caching** - Transformed images cached automatically
|
|
660
|
+
|
|
661
|
+
**How it works:**
|
|
662
|
+
|
|
663
|
+
When rendering, external image URLs are proxied through Cloudflare Images:
|
|
664
|
+
|
|
665
|
+
```
|
|
666
|
+
Original URL (from props):
|
|
667
|
+
https://cdn.example.com/photo.jpg
|
|
668
|
+
|
|
669
|
+
Transformed URL (internal):
|
|
670
|
+
https://api.loopwind.dev/cdn-cgi/image/width=800,height=600,fit=cover,format=auto/https://cdn.example.com/photo.jpg
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
**Transform options in templates:**
|
|
674
|
+
|
|
675
|
+
Templates can specify transform requirements in `meta.json`:
|
|
676
|
+
|
|
677
|
+
```json
|
|
282
678
|
{
|
|
283
|
-
"template": "username/promo-video",
|
|
284
679
|
"props": {
|
|
285
|
-
"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
680
|
+
"avatar": {
|
|
681
|
+
"type": "image",
|
|
682
|
+
"transform": {
|
|
683
|
+
"width": 200,
|
|
684
|
+
"height": 200,
|
|
685
|
+
"fit": "cover"
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
"background": {
|
|
689
|
+
"type": "image",
|
|
690
|
+
"transform": {
|
|
691
|
+
"width": 1200,
|
|
692
|
+
"height": 630,
|
|
693
|
+
"fit": "cover",
|
|
694
|
+
"format": "auto"
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
290
698
|
}
|
|
699
|
+
```
|
|
291
700
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
701
|
+
**Available transform options:**
|
|
702
|
+
|
|
703
|
+
| Option | Values | Description |
|
|
704
|
+
|--------|--------|-------------|
|
|
705
|
+
| `width` | 1-12000 | Target width in pixels |
|
|
706
|
+
| `height` | 1-12000 | Target height in pixels |
|
|
707
|
+
| `fit` | `cover`, `contain`, `scale-down`, `crop`, `pad` | How image fills dimensions |
|
|
708
|
+
| `format` | `auto`, `webp`, `avif`, `jpeg`, `png` | Output format (`auto` = best for browser) |
|
|
709
|
+
| `quality` | 1-100 | Quality for lossy formats (default: 85) |
|
|
710
|
+
|
|
711
|
+
**Query param overrides:**
|
|
712
|
+
|
|
713
|
+
Props can override transform settings via URL params:
|
|
714
|
+
|
|
715
|
+
```
|
|
716
|
+
# Use template default transform
|
|
717
|
+
GET /r/x7k9m2p4?avatar=https://example.com/photo.jpg
|
|
718
|
+
|
|
719
|
+
# Override width
|
|
720
|
+
GET /r/x7k9m2p4?avatar=https://example.com/photo.jpg&avatar_w=400
|
|
721
|
+
|
|
722
|
+
# Override multiple
|
|
723
|
+
GET /r/x7k9m2p4?avatar=https://example.com/photo.jpg&avatar_w=400&avatar_h=400&avatar_fit=contain
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**Direct image proxy (API):**
|
|
727
|
+
|
|
728
|
+
For advanced use cases, images can be transformed directly:
|
|
729
|
+
|
|
730
|
+
```
|
|
731
|
+
GET /img?src=https://example.com/photo.jpg&w=800&h=600&fit=cover&f=webp
|
|
732
|
+
|
|
733
|
+
→ Returns transformed image with headers:
|
|
734
|
+
Content-Type: image/webp
|
|
735
|
+
Cache-Control: public, max-age=31536000
|
|
736
|
+
CF-Cache-Status: HIT
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
**Benefits over raw URLs:**
|
|
740
|
+
|
|
741
|
+
| | Raw URL | With Transform |
|
|
742
|
+
|--|---------|----------------|
|
|
743
|
+
| File size | 2.5 MB | 150 KB (WebP) |
|
|
744
|
+
| Dimensions | 4000x3000 | 800x600 |
|
|
745
|
+
| Load time | 800ms | 50ms |
|
|
746
|
+
| Format | JPEG | WebP/AVIF (auto) |
|
|
747
|
+
| CDN | Origin only | Global edge |
|
|
748
|
+
|
|
749
|
+
---
|
|
750
|
+
|
|
751
|
+
### Signed URLs (Private Images)
|
|
752
|
+
|
|
753
|
+
For private templates or time-limited access, renders can be secured with [Cloudflare signed URLs](https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/).
|
|
754
|
+
|
|
755
|
+
**Use cases:**
|
|
756
|
+
- **Private templates** - Only users with valid signature can view
|
|
757
|
+
- **Time-limited sharing** - URLs expire after set duration
|
|
758
|
+
- **Prevent hotlinking** - Images only work on your domains
|
|
759
|
+
- **Audit trail** - Track who accessed what and when
|
|
760
|
+
|
|
761
|
+
**How it works:**
|
|
762
|
+
|
|
763
|
+
```
|
|
764
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
765
|
+
│ Your Server │
|
|
766
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
767
|
+
│ │
|
|
768
|
+
│ 1. User requests private image │
|
|
769
|
+
│ 2. Generate signed URL with expiration │
|
|
770
|
+
│ 3. Return signed URL to user │
|
|
771
|
+
│ │
|
|
772
|
+
│ const signedUrl = loopwind.signUrl(renderUrl, { │
|
|
773
|
+
│ expiresIn: 3600 // 1 hour │
|
|
774
|
+
│ }); │
|
|
775
|
+
│ │
|
|
776
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
777
|
+
│
|
|
778
|
+
▼
|
|
779
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
780
|
+
│ Signed URL │
|
|
781
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
782
|
+
│ │
|
|
783
|
+
│ https://api.loopwind.dev/r/x7k9m2p4 │
|
|
784
|
+
│ ?title=Hello │
|
|
785
|
+
│ &exp=1704067200 │
|
|
786
|
+
│ &sig=a1b2c3d4e5f6... │
|
|
787
|
+
│ │
|
|
788
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
789
|
+
│
|
|
790
|
+
▼
|
|
791
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
792
|
+
│ Loopwind API │
|
|
793
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
794
|
+
│ │
|
|
795
|
+
│ 1. Verify signature (HMAC-SHA256) │
|
|
796
|
+
│ 2. Check expiration timestamp │
|
|
797
|
+
│ 3. If valid → render image │
|
|
798
|
+
│ 4. If invalid → 403 Forbidden │
|
|
799
|
+
│ │
|
|
800
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**Generating signed URLs (server-side):**
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
import crypto from 'crypto';
|
|
807
|
+
|
|
808
|
+
function signRenderUrl(
|
|
809
|
+
renderUrl: string,
|
|
810
|
+
signingKey: string,
|
|
811
|
+
expiresIn: number = 3600 // seconds
|
|
812
|
+
): string {
|
|
813
|
+
const url = new URL(renderUrl);
|
|
814
|
+
const exp = Math.floor(Date.now() / 1000) + expiresIn;
|
|
815
|
+
|
|
816
|
+
// Add expiration to URL
|
|
817
|
+
url.searchParams.set('exp', String(exp));
|
|
818
|
+
|
|
819
|
+
// Generate signature from path + query (excluding sig param)
|
|
820
|
+
const dataToSign = url.pathname + url.search;
|
|
821
|
+
const sig = crypto
|
|
822
|
+
.createHmac('sha256', signingKey)
|
|
823
|
+
.update(dataToSign)
|
|
824
|
+
.digest('hex');
|
|
825
|
+
|
|
826
|
+
url.searchParams.set('sig', sig);
|
|
827
|
+
return url.toString();
|
|
298
828
|
}
|
|
829
|
+
|
|
830
|
+
// Usage
|
|
831
|
+
const signingKey = process.env.LOOPWIND_SIGNING_KEY;
|
|
832
|
+
const signedUrl = signRenderUrl(
|
|
833
|
+
'https://api.loopwind.dev/r/x7k9m2p4?title=Private+Doc',
|
|
834
|
+
signingKey,
|
|
835
|
+
3600 // Valid for 1 hour
|
|
836
|
+
);
|
|
299
837
|
```
|
|
300
838
|
|
|
301
|
-
|
|
839
|
+
**Dashboard UI (Settings → Signing Keys):**
|
|
302
840
|
|
|
303
841
|
```
|
|
304
|
-
|
|
305
|
-
|
|
842
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
843
|
+
│ Settings › Signing Keys │
|
|
844
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
845
|
+
│ │
|
|
846
|
+
│ Use signing keys to generate secure, time-limited URLs for │
|
|
847
|
+
│ private templates. │
|
|
848
|
+
│ │
|
|
849
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
850
|
+
│ │ Production Key │ │
|
|
851
|
+
│ │ sk_live_•••••••••••••••• [Reveal] │ │
|
|
852
|
+
│ │ Created: Jan 5, 2025 [Rotate] │ │
|
|
853
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
854
|
+
│ │
|
|
855
|
+
│ ┌───────────────────────────────────────┐ │
|
|
856
|
+
│ │ + Generate New Key │ │
|
|
857
|
+
│ └───────────────────────────────────────┘ │
|
|
858
|
+
│ │
|
|
859
|
+
│ ⚠️ Keep signing keys secret! Never expose in client-side code. │
|
|
860
|
+
│ │
|
|
861
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
862
|
+
```
|
|
306
863
|
|
|
307
|
-
|
|
864
|
+
**API for key management:**
|
|
865
|
+
|
|
866
|
+
```
|
|
867
|
+
POST /api/organizations/:org_id/signing-keys
|
|
868
|
+
→ 201 { "id": "sk_xxxxx", "key": "sk_live_...", "createdAt": "..." }
|
|
869
|
+
|
|
870
|
+
GET /api/organizations/:org_id/signing-keys
|
|
871
|
+
→ 200 [{ "id": "sk_xxxxx", "prefix": "sk_live_****", "createdAt": "..." }]
|
|
872
|
+
|
|
873
|
+
DELETE /api/organizations/:org_id/signing-keys/:id
|
|
874
|
+
→ 204
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
**Template-level privacy:**
|
|
878
|
+
|
|
879
|
+
```json
|
|
880
|
+
// meta.json
|
|
881
|
+
{
|
|
882
|
+
"name": "confidential-report",
|
|
883
|
+
"private": true,
|
|
884
|
+
"requireSignature": true,
|
|
885
|
+
"signatureExpiry": 3600
|
|
886
|
+
}
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
**Error responses:**
|
|
890
|
+
|
|
891
|
+
```
|
|
892
|
+
# Missing signature on private template
|
|
893
|
+
GET /r/x7k9m2p4?title=Hello
|
|
894
|
+
→ 403 { "error": "signature_required", "message": "This template requires a signed URL" }
|
|
895
|
+
|
|
896
|
+
# Expired signature
|
|
897
|
+
GET /r/x7k9m2p4?title=Hello&exp=1704000000&sig=abc123
|
|
898
|
+
→ 403 { "error": "signature_expired", "message": "URL expired at 2024-12-31T12:00:00Z" }
|
|
899
|
+
|
|
900
|
+
# Invalid signature
|
|
901
|
+
GET /r/x7k9m2p4?title=Hello&exp=1735689600&sig=invalid
|
|
902
|
+
→ 403 { "error": "signature_invalid", "message": "Signature verification failed" }
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
---
|
|
906
|
+
|
|
907
|
+
### Version Pinning
|
|
908
|
+
|
|
909
|
+
By default, render URLs use the latest published version. Pin to a specific version for stability:
|
|
910
|
+
|
|
911
|
+
```
|
|
912
|
+
# Latest version (default)
|
|
913
|
+
GET /r/x7k9m2p4?title=Hello
|
|
914
|
+
|
|
915
|
+
# Pinned to v1.0.0
|
|
916
|
+
GET /r/x7k9m2p4?title=Hello&v=1.0.0
|
|
917
|
+
|
|
918
|
+
# Explicit latest
|
|
919
|
+
GET /r/x7k9m2p4?title=Hello&v=latest
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
**Why pin versions:**
|
|
923
|
+
- OG images shouldn't change unexpectedly after you share them
|
|
924
|
+
- Production apps need stability
|
|
925
|
+
- Roll back if new version has issues
|
|
926
|
+
|
|
927
|
+
**Version in POST body:**
|
|
928
|
+
|
|
929
|
+
```bash
|
|
930
|
+
curl -X POST https://api.loopwind.dev/r/x7k9m2p4 \
|
|
931
|
+
-H "Content-Type: application/json" \
|
|
932
|
+
-d '{"props": {"title": "Hello"}, "version": "1.0.0"}'
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
**Cache behavior:**
|
|
936
|
+
- Different versions = different cache keys
|
|
937
|
+
- Updating template creates new version, doesn't invalidate old version caches
|
|
938
|
+
- Pinned URLs are stable; unpinned URLs update when you publish
|
|
939
|
+
|
|
940
|
+
---
|
|
941
|
+
|
|
942
|
+
### Custom Domains
|
|
943
|
+
|
|
944
|
+
Use your own domain instead of `api.loopwind.dev`:
|
|
945
|
+
|
|
946
|
+
```
|
|
947
|
+
# Instead of:
|
|
948
|
+
https://api.loopwind.dev/r/x7k9m2p4?title=Hello
|
|
949
|
+
|
|
950
|
+
# Use:
|
|
951
|
+
https://og.mysite.com/card?title=Hello
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
**Setup in dashboard (Settings → Custom Domains):**
|
|
955
|
+
|
|
956
|
+
```
|
|
957
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
958
|
+
│ Settings › Custom Domains │
|
|
959
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
960
|
+
│ │
|
|
961
|
+
│ Custom domains let you use your own URLs for render requests. │
|
|
962
|
+
│ │
|
|
963
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
964
|
+
│ │ og.mysite.com [Verified] │ │
|
|
965
|
+
│ │ → Routes to: blog-og template │ │
|
|
966
|
+
│ │ │ │
|
|
967
|
+
│ │ images.mysite.com [Verified] │ │
|
|
968
|
+
│ │ → Routes to: all templates (use /template-name) │ │
|
|
969
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
970
|
+
│ │
|
|
971
|
+
│ ┌─────────────────────────────────┐ ┌───────────────────┐ │
|
|
972
|
+
│ │ Add domain │ │ + Add Domain │ │
|
|
973
|
+
│ └─────────────────────────────────┘ └───────────────────┘ │
|
|
974
|
+
│ │
|
|
975
|
+
│ To verify, add a CNAME record: │
|
|
976
|
+
│ og.mysite.com → proxy.loopwind.dev │
|
|
977
|
+
│ │
|
|
978
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
**DNS setup:**
|
|
982
|
+
|
|
983
|
+
```
|
|
984
|
+
# Add CNAME record in your DNS provider
|
|
985
|
+
og.mysite.com CNAME proxy.loopwind.dev
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
**Routing options:**
|
|
989
|
+
|
|
990
|
+
| Mode | URL Pattern | Use Case |
|
|
991
|
+
|------|-------------|----------|
|
|
992
|
+
| Single template | `og.mysite.com?title=Hello` | One domain = one template |
|
|
993
|
+
| Multi template | `og.mysite.com/blog-og?title=Hello` | One domain = multiple templates |
|
|
994
|
+
|
|
995
|
+
**API endpoints:**
|
|
996
|
+
|
|
997
|
+
```
|
|
998
|
+
POST /api/organizations/:org_id/domains
|
|
999
|
+
{ "domain": "og.mysite.com", "templateId": "tpl_abc" }
|
|
1000
|
+
→ 201 { "domain": "og.mysite.com", "verificationRecord": "proxy.loopwind.dev", "verified": false }
|
|
1001
|
+
|
|
1002
|
+
GET /api/organizations/:org_id/domains
|
|
1003
|
+
→ 200 { "domains": [...] }
|
|
1004
|
+
|
|
1005
|
+
DELETE /api/organizations/:org_id/domains/:domain
|
|
1006
|
+
→ 204
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
---
|
|
1010
|
+
|
|
1011
|
+
### POST - API-Based Rendering
|
|
1012
|
+
|
|
1013
|
+
Best for complex props, arrays, nested objects, programmatic use.
|
|
1014
|
+
|
|
1015
|
+
```bash
|
|
1016
|
+
curl -X POST https://api.loopwind.dev/r/x7k9m2p4 \
|
|
1017
|
+
-H "Content-Type: application/json" \
|
|
1018
|
+
-d '{
|
|
1019
|
+
"props": {
|
|
1020
|
+
"title": "Hello World",
|
|
1021
|
+
"subtitle": "Welcome to Loopwind",
|
|
1022
|
+
"features": ["Fast", "Simple", "Beautiful"],
|
|
1023
|
+
"author": {
|
|
1024
|
+
"name": "Jane Doe",
|
|
1025
|
+
"avatar": "https://example.com/avatar.jpg"
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
"format": "png",
|
|
1029
|
+
"width": 1200,
|
|
1030
|
+
"height": 630
|
|
1031
|
+
}' \
|
|
1032
|
+
--output image.png
|
|
1033
|
+
|
|
1034
|
+
→ 200 (binary image data)
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
**Request Body:**
|
|
1038
|
+
|
|
1039
|
+
```typescript
|
|
1040
|
+
interface RenderRequest {
|
|
1041
|
+
// Template props (required)
|
|
1042
|
+
props: Record<string, any>;
|
|
1043
|
+
|
|
1044
|
+
// Version pinning (optional, default: "latest")
|
|
1045
|
+
version?: string; // "1.0.0" | "latest"
|
|
1046
|
+
|
|
1047
|
+
// Output format (optional, default: "png")
|
|
1048
|
+
format?: "png" | "webp" | "jpg" | "svg" | "mp4" | "gif";
|
|
1049
|
+
|
|
1050
|
+
// Size override (optional, uses template defaults)
|
|
1051
|
+
width?: number;
|
|
1052
|
+
height?: number;
|
|
1053
|
+
|
|
1054
|
+
// Video options (only for mp4/gif)
|
|
1055
|
+
fps?: number;
|
|
1056
|
+
duration?: number;
|
|
1057
|
+
crf?: number;
|
|
1058
|
+
|
|
1059
|
+
// Webhook for async video rendering
|
|
1060
|
+
webhook?: string;
|
|
1061
|
+
}
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
**Examples:**
|
|
1065
|
+
|
|
1066
|
+
```bash
|
|
1067
|
+
# Simple image
|
|
1068
|
+
curl -X POST https://api.loopwind.dev/r/x7k9m2p4 \
|
|
1069
|
+
-H "Content-Type: application/json" \
|
|
1070
|
+
-d '{"props": {"title": "Hello"}}' \
|
|
1071
|
+
--output hello.png
|
|
1072
|
+
|
|
1073
|
+
# With format and size
|
|
1074
|
+
curl -X POST https://api.loopwind.dev/r/x7k9m2p4 \
|
|
1075
|
+
-H "Content-Type: application/json" \
|
|
1076
|
+
-d '{"props": {"title": "Hello"}, "format": "webp", "width": 800}' \
|
|
1077
|
+
--output hello.webp
|
|
1078
|
+
|
|
1079
|
+
# Array props (changelog video)
|
|
1080
|
+
curl -X POST https://api.loopwind.dev/r/abc123 \
|
|
1081
|
+
-H "Content-Type: application/json" \
|
|
1082
|
+
-d '{
|
|
1083
|
+
"props": {
|
|
1084
|
+
"version": "v2.0.0",
|
|
1085
|
+
"title": "Major Release",
|
|
1086
|
+
"changes": [
|
|
1087
|
+
"New dashboard UI",
|
|
1088
|
+
"Fixed authentication bug",
|
|
1089
|
+
"Improved performance by 50%"
|
|
1090
|
+
]
|
|
1091
|
+
},
|
|
1092
|
+
"format": "mp4"
|
|
1093
|
+
}'
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
**JavaScript/TypeScript:**
|
|
1097
|
+
|
|
1098
|
+
```typescript
|
|
1099
|
+
// POST with complex props
|
|
1100
|
+
const response = await fetch('https://api.loopwind.dev/r/x7k9m2p4', {
|
|
1101
|
+
method: 'POST',
|
|
1102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1103
|
+
body: JSON.stringify({
|
|
1104
|
+
props: {
|
|
1105
|
+
title: 'My Blog Post',
|
|
1106
|
+
tags: ['tech', 'tutorial', 'react'],
|
|
1107
|
+
author: { name: 'Jane', avatar: 'https://...' }
|
|
1108
|
+
},
|
|
1109
|
+
format: 'webp'
|
|
1110
|
+
})
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
const image = await response.arrayBuffer();
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
**Python:**
|
|
1117
|
+
|
|
1118
|
+
```python
|
|
1119
|
+
import requests
|
|
1120
|
+
|
|
1121
|
+
response = requests.post(
|
|
1122
|
+
'https://api.loopwind.dev/r/x7k9m2p4',
|
|
1123
|
+
json={
|
|
1124
|
+
'props': {
|
|
1125
|
+
'title': 'Hello World',
|
|
1126
|
+
'items': ['One', 'Two', 'Three']
|
|
1127
|
+
},
|
|
1128
|
+
'format': 'png'
|
|
1129
|
+
}
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
with open('output.png', 'wb') as f:
|
|
1133
|
+
f.write(response.content)
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
---
|
|
1137
|
+
|
|
1138
|
+
### GET vs POST
|
|
1139
|
+
|
|
1140
|
+
| | GET | POST |
|
|
1141
|
+
|---|---|---|
|
|
1142
|
+
| Props | Query string | JSON body |
|
|
1143
|
+
| Caching | CDN cacheable | Not cacheable by default |
|
|
1144
|
+
| Use case | OG images, `<img>` tags | API calls, complex data |
|
|
1145
|
+
| Arrays/Objects | URL-encoded (messy) | Native JSON |
|
|
1146
|
+
| Max size | ~2KB URL limit | 10MB body |
|
|
1147
|
+
|
|
1148
|
+
**When to use GET:**
|
|
1149
|
+
- OG meta tags (`<meta property="og:image">`)
|
|
1150
|
+
- Direct `<img src="...">` usage
|
|
1151
|
+
- Simple string props
|
|
1152
|
+
- When caching is important
|
|
1153
|
+
|
|
1154
|
+
**When to use POST:**
|
|
1155
|
+
- Complex/nested props
|
|
1156
|
+
- Arrays (like changelog items)
|
|
1157
|
+
- Programmatic rendering (scripts, CI/CD)
|
|
1158
|
+
- Large prop values
|
|
1159
|
+
|
|
1160
|
+
### Video Rendering (Asynchronous)
|
|
1161
|
+
|
|
1162
|
+
Videos require async processing. Add `format=mp4` or `format=gif`:
|
|
1163
|
+
|
|
1164
|
+
```
|
|
1165
|
+
GET https://api.loopwind.dev/r/x7k9m2p4?title=Launch&format=mp4
|
|
1166
|
+
|
|
1167
|
+
→ 202 {
|
|
308
1168
|
"jobId": "job_abc123",
|
|
309
|
-
"status": "
|
|
310
|
-
"
|
|
311
|
-
"
|
|
312
|
-
"totalFrames": 100,
|
|
313
|
-
"createdAt": "2025-01-05T...",
|
|
314
|
-
"startedAt": "2025-01-05T..."
|
|
1169
|
+
"status": "queued",
|
|
1170
|
+
"statusUrl": "https://api.loopwind.dev/jobs/job_abc123",
|
|
1171
|
+
"estimatedDuration": 15000
|
|
315
1172
|
}
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
**Poll for completion:**
|
|
1176
|
+
|
|
1177
|
+
```
|
|
1178
|
+
GET https://api.loopwind.dev/jobs/job_abc123
|
|
316
1179
|
|
|
317
|
-
// When completed:
|
|
318
1180
|
→ 200 {
|
|
319
|
-
"jobId": "job_abc123",
|
|
320
1181
|
"status": "completed",
|
|
321
|
-
"progress": 1,
|
|
322
1182
|
"outputUrl": "https://cdn.loopwind.dev/renders/job_abc123.mp4",
|
|
323
|
-
"expiresAt": "2025-01-06T..."
|
|
324
|
-
"duration": 12500, // Render time in ms
|
|
325
|
-
"completedAt": "2025-01-05T..."
|
|
1183
|
+
"expiresAt": "2025-01-06T..."
|
|
326
1184
|
}
|
|
327
1185
|
```
|
|
328
1186
|
|
|
329
|
-
### Webhook
|
|
1187
|
+
### Webhook (Optional)
|
|
330
1188
|
|
|
1189
|
+
Add a webhook query param to get notified when video is ready:
|
|
1190
|
+
|
|
1191
|
+
```
|
|
1192
|
+
GET https://api.loopwind.dev/r/x7k9m2p4?title=Launch&format=mp4&webhook=https://myapp.com/hooks/render
|
|
331
1193
|
```
|
|
332
|
-
POST https://myapp.com/hooks/render-complete
|
|
333
|
-
Content-Type: application/json
|
|
334
|
-
X-Loopwind-Signature: sha256=...
|
|
335
1194
|
|
|
1195
|
+
```json
|
|
1196
|
+
// POST to your webhook
|
|
336
1197
|
{
|
|
337
1198
|
"event": "render.completed",
|
|
338
1199
|
"jobId": "job_abc123",
|
|
339
|
-
"
|
|
340
|
-
"outputUrl": "https://cdn.loopwind.dev/renders/job_abc123.mp4",
|
|
341
|
-
"template": "username/promo-video",
|
|
342
|
-
"format": "mp4",
|
|
343
|
-
"duration": 12500,
|
|
344
|
-
"timestamp": "2025-01-05T..."
|
|
1200
|
+
"outputUrl": "https://cdn.loopwind.dev/renders/job_abc123.mp4"
|
|
345
1201
|
}
|
|
346
1202
|
```
|
|
347
1203
|
|
|
348
|
-
### CLI
|
|
1204
|
+
### CLI Output After Publish
|
|
349
1205
|
|
|
350
1206
|
```bash
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
✓
|
|
354
|
-
|
|
1207
|
+
$ loopwind publish og-card
|
|
1208
|
+
|
|
1209
|
+
✓ Published og-card v1.0.0
|
|
1210
|
+
|
|
1211
|
+
Included:
|
|
1212
|
+
- template.tsx (2.1 KB)
|
|
1213
|
+
- template.js (1.4 KB)
|
|
1214
|
+
- config: 12 colors, 2 fonts
|
|
1215
|
+
- assets: logo.png (15 KB)
|
|
1216
|
+
|
|
1217
|
+
Render URL: https://api.loopwind.dev/r/x7k9m2p4
|
|
1218
|
+
|
|
1219
|
+
Usage:
|
|
1220
|
+
<img src="https://api.loopwind.dev/r/x7k9m2p4?title=Hello" />
|
|
1221
|
+
|
|
1222
|
+
Props:
|
|
1223
|
+
- title (string, required)
|
|
1224
|
+
- subtitle (string, optional)
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
### Render Flow (with Caching)
|
|
1228
|
+
|
|
1229
|
+
Rendered images are cached in R2 for fast subsequent requests:
|
|
1230
|
+
|
|
1231
|
+
```
|
|
1232
|
+
GET /r/x7k9m2p4?title=Hello
|
|
1233
|
+
│
|
|
1234
|
+
▼
|
|
1235
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
1236
|
+
│ 1. Cache Check (R2) │
|
|
1237
|
+
│ │
|
|
1238
|
+
│ cache_key = hash(template_id + props + format + size) │
|
|
1239
|
+
│ cached = await R2.get(cache_key) │
|
|
1240
|
+
│ │
|
|
1241
|
+
│ if (cached) { │
|
|
1242
|
+
│ track('hit', template_id) ← Analytics Engine │
|
|
1243
|
+
│ return cached ← Fast path (~10ms) │
|
|
1244
|
+
│ } │
|
|
1245
|
+
└────────────────────────────────────────────────────────────────┘
|
|
1246
|
+
│ (cache miss)
|
|
1247
|
+
▼
|
|
1248
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
1249
|
+
│ 2. Lookup & Load │
|
|
1250
|
+
│ │
|
|
1251
|
+
│ Worker: token → D1 → org_id + template_id │
|
|
1252
|
+
│ Org DO: load template.js, config, fonts │
|
|
1253
|
+
└────────────────────────────────────────────────────────────────┘
|
|
1254
|
+
│
|
|
1255
|
+
▼
|
|
1256
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
1257
|
+
│ 3. Render (SDK) │
|
|
1258
|
+
│ │
|
|
1259
|
+
│ import { createRenderer } from 'loopwind'; │
|
|
1260
|
+
│ │
|
|
1261
|
+
│ // Create renderer with stored config │
|
|
1262
|
+
│ const render = createRenderer({ │
|
|
1263
|
+
│ colors: config.colors, │
|
|
1264
|
+
│ fonts: config.fonts, │
|
|
1265
|
+
│ tokens: config.tokens, │
|
|
1266
|
+
│ fontFiles: loadedFontFiles, │
|
|
1267
|
+
│ }); │
|
|
1268
|
+
│ │
|
|
1269
|
+
│ // Execute stored template.js │
|
|
1270
|
+
│ const Template = await import(templateJsBlob); │
|
|
1271
|
+
│ │
|
|
1272
|
+
│ const png = await render(Template.default, { │
|
|
1273
|
+
│ props: { title: 'Hello' }, │
|
|
1274
|
+
│ width: meta.width, │
|
|
1275
|
+
│ height: meta.height, │
|
|
1276
|
+
│ format: 'png', │
|
|
1277
|
+
│ }); │
|
|
1278
|
+
└────────────────────────────────────────────────────────────────┘
|
|
1279
|
+
│
|
|
1280
|
+
▼
|
|
1281
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
1282
|
+
│ 4. Cache & Track │
|
|
1283
|
+
│ │
|
|
1284
|
+
│ // Store in R2 for future requests │
|
|
1285
|
+
│ await R2.put(cache_key, png, { │
|
|
1286
|
+
│ httpMetadata: { contentType: 'image/png' }, │
|
|
1287
|
+
│ customMetadata: { template_id, org_id } │
|
|
1288
|
+
│ }); │
|
|
1289
|
+
│ │
|
|
1290
|
+
│ // Track render (not hit - this was a fresh render) │
|
|
1291
|
+
│ track('render', template_id) ← Analytics Engine │
|
|
1292
|
+
│ │
|
|
1293
|
+
│ return png; │
|
|
1294
|
+
└────────────────────────────────────────────────────────────────┘
|
|
1295
|
+
```
|
|
1296
|
+
|
|
1297
|
+
**Summary:**
|
|
1298
|
+
- **Hit** = Served from R2 cache (~10ms)
|
|
1299
|
+
- **Render** = Fresh render, then cached (~200-2000ms)
|
|
1300
|
+
|
|
1301
|
+
---
|
|
355
1302
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1303
|
+
### Caching Strategy
|
|
1304
|
+
|
|
1305
|
+
**R2 Cache Keys:**
|
|
1306
|
+
|
|
1307
|
+
```typescript
|
|
1308
|
+
// Cache key = deterministic hash of all render inputs INCLUDING version
|
|
1309
|
+
function getCacheKey(
|
|
1310
|
+
templateId: string,
|
|
1311
|
+
version: string, // "1.0.0" or "latest" resolved to actual version
|
|
1312
|
+
props: object,
|
|
1313
|
+
format: string,
|
|
1314
|
+
width: number,
|
|
1315
|
+
height: number
|
|
1316
|
+
): string {
|
|
1317
|
+
const input = JSON.stringify({ templateId, version, props, format, width, height });
|
|
1318
|
+
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 32);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Example: "a3f8c2e1b4d9..."
|
|
1322
|
+
// Different versions = different cache keys = no accidental cache hits
|
|
1323
|
+
```
|
|
1324
|
+
|
|
1325
|
+
**Cache Invalidation:**
|
|
1326
|
+
|
|
1327
|
+
| Event | Action |
|
|
1328
|
+
|-------|--------|
|
|
1329
|
+
| Template updated | Delete all cached renders for template |
|
|
1330
|
+
| Manual purge | `DELETE /api/templates/:id/cache` |
|
|
1331
|
+
| TTL expiry | R2 lifecycle rule (default: 7 days) |
|
|
1332
|
+
|
|
1333
|
+
```typescript
|
|
1334
|
+
// Invalidate cache when template is updated
|
|
1335
|
+
async function invalidateTemplateCache(templateId: string) {
|
|
1336
|
+
const objects = await R2.list({ prefix: `renders/${templateId}/` });
|
|
1337
|
+
await Promise.all(objects.objects.map(obj => R2.delete(obj.key)));
|
|
1338
|
+
}
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
**R2 Storage Structure:**
|
|
1342
|
+
|
|
1343
|
+
```
|
|
1344
|
+
renders/
|
|
1345
|
+
├── {template_id}/
|
|
1346
|
+
│ ├── {cache_key}.png
|
|
1347
|
+
│ ├── {cache_key}.webp
|
|
1348
|
+
│ └── {cache_key}.mp4
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
---
|
|
1352
|
+
|
|
1353
|
+
### Analytics & Tracking
|
|
1354
|
+
|
|
1355
|
+
Use Cloudflare Analytics Engine for high-throughput, low-latency tracking:
|
|
1356
|
+
|
|
1357
|
+
**Why Analytics Engine:**
|
|
1358
|
+
- Handles millions of events/second
|
|
1359
|
+
- No blocking - fire and forget
|
|
1360
|
+
- Built-in aggregation queries
|
|
1361
|
+
- 90-day retention (configurable)
|
|
1362
|
+
|
|
1363
|
+
**Track Two Event Types:**
|
|
1364
|
+
|
|
1365
|
+
```typescript
|
|
1366
|
+
// In render worker
|
|
1367
|
+
analytics.writeDataPoint({
|
|
1368
|
+
blobs: [org_id, template_id, format],
|
|
1369
|
+
doubles: [1], // count
|
|
1370
|
+
indexes: [event_type] // 'render' or 'hit'
|
|
1371
|
+
});
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
| Event | When | Meaning |
|
|
1375
|
+
|-------|------|---------|
|
|
1376
|
+
| `render` | Cache miss, fresh render | Actual CPU/rendering work |
|
|
1377
|
+
| `hit` | Cache hit from R2 | Fast serve, no rendering |
|
|
1378
|
+
|
|
1379
|
+
**Query Analytics:**
|
|
1380
|
+
|
|
1381
|
+
```typescript
|
|
1382
|
+
// Get stats for a template
|
|
1383
|
+
const stats = await analytics.query(`
|
|
1384
|
+
SELECT
|
|
1385
|
+
index1 as event_type,
|
|
1386
|
+
sum(double1) as count
|
|
1387
|
+
FROM analytics
|
|
1388
|
+
WHERE
|
|
1389
|
+
blob1 = '${org_id}'
|
|
1390
|
+
AND blob2 = '${template_id}'
|
|
1391
|
+
AND timestamp > now() - interval '30' day
|
|
1392
|
+
GROUP BY index1
|
|
1393
|
+
`);
|
|
1394
|
+
|
|
1395
|
+
// Returns: [{ event_type: 'render', count: 150 }, { event_type: 'hit', count: 4500 }]
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
**Dashboard Stats API:**
|
|
1399
|
+
|
|
1400
|
+
```
|
|
1401
|
+
GET /api/organizations/:org_id/analytics
|
|
1402
|
+
?period=7d|30d|90d
|
|
1403
|
+
?template_id=optional
|
|
1404
|
+
|
|
1405
|
+
→ 200 {
|
|
1406
|
+
"period": "30d",
|
|
1407
|
+
"totals": {
|
|
1408
|
+
"renders": 1250,
|
|
1409
|
+
"hits": 45000,
|
|
1410
|
+
"cacheHitRate": 0.973
|
|
1411
|
+
},
|
|
1412
|
+
"byTemplate": [
|
|
1413
|
+
{
|
|
1414
|
+
"template_id": "tpl_abc",
|
|
1415
|
+
"name": "og-card",
|
|
1416
|
+
"renders": 500,
|
|
1417
|
+
"hits": 20000,
|
|
1418
|
+
"cacheHitRate": 0.975
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
"template_id": "tpl_def",
|
|
1422
|
+
"name": "changelog-video",
|
|
1423
|
+
"renders": 750,
|
|
1424
|
+
"hits": 25000,
|
|
1425
|
+
"cacheHitRate": 0.971
|
|
1426
|
+
}
|
|
1427
|
+
],
|
|
1428
|
+
"timeSeries": [
|
|
1429
|
+
{ "date": "2025-01-01", "renders": 40, "hits": 1500 },
|
|
1430
|
+
{ "date": "2025-01-02", "renders": 45, "hits": 1600 },
|
|
1431
|
+
...
|
|
1432
|
+
]
|
|
1433
|
+
}
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
**Per-Template Stats:**
|
|
1437
|
+
|
|
1438
|
+
```
|
|
1439
|
+
GET /api/templates/:id/analytics
|
|
1440
|
+
?period=7d|30d|90d
|
|
1441
|
+
|
|
1442
|
+
→ 200 {
|
|
1443
|
+
"template": {
|
|
1444
|
+
"id": "tpl_abc",
|
|
1445
|
+
"name": "og-card"
|
|
1446
|
+
},
|
|
1447
|
+
"period": "30d",
|
|
1448
|
+
"renders": 500,
|
|
1449
|
+
"hits": 20000,
|
|
1450
|
+
"cacheHitRate": 0.975,
|
|
1451
|
+
"uniqueProps": 450, // Different prop combinations
|
|
1452
|
+
"avgRenderTime": 234, // ms
|
|
1453
|
+
"timeSeries": [...],
|
|
1454
|
+
"topProps": [
|
|
1455
|
+
{ "props": {"title": "Welcome"}, "hits": 5000 },
|
|
1456
|
+
{ "props": {"title": "About Us"}, "hits": 3200 }
|
|
1457
|
+
]
|
|
1458
|
+
}
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
**Organization Dashboard Overview:**
|
|
1462
|
+
|
|
1463
|
+
```
|
|
1464
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
1465
|
+
│ Organization: my-org Last 30 days │
|
|
1466
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1467
|
+
│ │
|
|
1468
|
+
│ Total Requests Renders Hits Cache Rate │
|
|
1469
|
+
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
1470
|
+
│ │ 46,250 │ │ 1,250 │ │ 45,000 │ │ 97.3% │ │
|
|
1471
|
+
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
|
1472
|
+
│ │
|
|
1473
|
+
│ Templates │
|
|
1474
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
1475
|
+
│ │ Name Renders Hits Cache Avg Time │ │
|
|
1476
|
+
│ ├─────────────────────────────────────────────────────────┤ │
|
|
1477
|
+
│ │ og-card 500 20,000 97.5% 234ms │ │
|
|
1478
|
+
│ │ changelog-video 750 25,000 97.1% 1,240ms │ │
|
|
1479
|
+
│ │ banner-hero 50 5,000 99.0% 189ms │ │
|
|
1480
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
1481
|
+
│ │
|
|
1482
|
+
│ Request Volume (30 days) │
|
|
1483
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
1484
|
+
│ │ █ │ │
|
|
1485
|
+
│ │ █ █ █ │ │
|
|
1486
|
+
│ │ █ █ █ █ █ █ █ │ │
|
|
1487
|
+
│ │ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ │ │
|
|
1488
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
1489
|
+
│ │
|
|
1490
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
361
1491
|
```
|
|
362
1492
|
|
|
363
1493
|
---
|
|
@@ -433,7 +1563,7 @@ Authorization: Bearer <token>
|
|
|
433
1563
|
### Business Tier ($99/month)
|
|
434
1564
|
- 50,000 image renders/month
|
|
435
1565
|
- 5,000 video renders/month
|
|
436
|
-
-
|
|
1566
|
+
- Organization accounts
|
|
437
1567
|
- Custom domains
|
|
438
1568
|
- SLA guarantee
|
|
439
1569
|
- Rate limit: 500 req/min
|
|
@@ -456,16 +1586,214 @@ X-RateLimit-Reset: 1704499200
|
|
|
456
1586
|
| Component | Technology | Why |
|
|
457
1587
|
|-----------|------------|-----|
|
|
458
1588
|
| API Gateway | Cloudflare Workers | Rate limiting, auth, routing - all at edge |
|
|
459
|
-
| Auth | Workers + D1 | JWT validation,
|
|
460
|
-
| Database | D1 (SQLite)
|
|
461
|
-
|
|
|
462
|
-
|
|
|
463
|
-
|
|
|
1589
|
+
| Auth | Workers + D1 | JWT validation, global user accounts |
|
|
1590
|
+
| Global Database | D1 (SQLite) | Users, API keys, billing - shared across organizations |
|
|
1591
|
+
| Analytics | Analytics Engine | High-throughput render/hit tracking, millisecond writes |
|
|
1592
|
+
| Org Storage | Durable Objects + SQLite | Templates, files, jobs - isolated per organization |
|
|
1593
|
+
| Cache | Workers KV | Sessions, rate limits, font cache |
|
|
1594
|
+
| Render Cache | R2 | Rendered outputs (images/videos) |
|
|
464
1595
|
| CDN | Cloudflare CDN | Automatic, integrated with R2 |
|
|
465
1596
|
| Image Rendering | Workers (WASM) | Satori + Resvg, up to 5min CPU |
|
|
466
1597
|
| Video Rendering | Containers | FFmpeg, unlimited duration, GPU support |
|
|
467
1598
|
| Monitoring | Workers Analytics + Sentry | Built-in metrics + error tracking |
|
|
468
1599
|
|
|
1600
|
+
### Data Architecture: DO SQLite per Organization
|
|
1601
|
+
|
|
1602
|
+
Each organization gets its own Durable Object with embedded SQLite database. This provides:
|
|
1603
|
+
|
|
1604
|
+
- **Isolation** - Organization data is completely separate
|
|
1605
|
+
- **Performance** - Data co-located, no cross-region queries
|
|
1606
|
+
- **Consistency** - Strong transactional guarantees within organization
|
|
1607
|
+
- **Scalability** - Organizations scale independently
|
|
1608
|
+
|
|
1609
|
+
```
|
|
1610
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
1611
|
+
│ Global Layer (D1) │
|
|
1612
|
+
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
1613
|
+
│ │ users, api_keys, organizations, billing, usage_summary │ │
|
|
1614
|
+
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
1615
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
1616
|
+
│
|
|
1617
|
+
┌───────────────┼───────────────┐
|
|
1618
|
+
▼ ▼ ▼
|
|
1619
|
+
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
|
|
1620
|
+
│ OrgDO: org_abc │ │ OrgDO: org_def │ │ OrgDO: org_ghi │
|
|
1621
|
+
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │ │ ┌─────────────────┐ │
|
|
1622
|
+
│ │ SQLite Database │ │ │ │ SQLite Database │ │ │ │ SQLite Database │ │
|
|
1623
|
+
│ │ │ │ │ │ │ │ │ │ │ │
|
|
1624
|
+
│ │ • templates │ │ │ │ • templates │ │ │ │ • templates │ │
|
|
1625
|
+
│ │ • template_files│ │ │ │ • template_files│ │ │ │ • template_files│ │
|
|
1626
|
+
│ │ • render_jobs │ │ │ │ • render_jobs │ │ │ │ • render_jobs │ │
|
|
1627
|
+
│ │ • assets │ │ │ │ • assets │ │ │ │ • assets │ │
|
|
1628
|
+
│ └─────────────────┘ │ │ └─────────────────┘ │ │ └─────────────────┘ │
|
|
1629
|
+
└───────────────────────┘ └───────────────────────┘ └───────────────────────┘
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
#### Org Durable Object
|
|
1633
|
+
|
|
1634
|
+
```typescript
|
|
1635
|
+
export class OrgStorage extends DurableObject {
|
|
1636
|
+
sql: SqlStorage;
|
|
1637
|
+
|
|
1638
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
1639
|
+
super(ctx, env);
|
|
1640
|
+
this.sql = ctx.storage.sql;
|
|
1641
|
+
|
|
1642
|
+
// Initialize schema on first access
|
|
1643
|
+
this.sql.exec(`
|
|
1644
|
+
CREATE TABLE IF NOT EXISTS templates (
|
|
1645
|
+
id TEXT PRIMARY KEY,
|
|
1646
|
+
name TEXT NOT NULL UNIQUE,
|
|
1647
|
+
description TEXT,
|
|
1648
|
+
type TEXT NOT NULL, -- 'image' | 'video'
|
|
1649
|
+
meta TEXT NOT NULL, -- JSON: { width, height, fps, duration, props }
|
|
1650
|
+
config TEXT, -- JSON: loopwind.json (colors, fonts, tokens)
|
|
1651
|
+
private INTEGER DEFAULT 0,
|
|
1652
|
+
tags TEXT, -- JSON array
|
|
1653
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
1654
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
1655
|
+
);
|
|
1656
|
+
|
|
1657
|
+
CREATE TABLE IF NOT EXISTS template_files (
|
|
1658
|
+
id TEXT PRIMARY KEY,
|
|
1659
|
+
template_id TEXT NOT NULL REFERENCES templates(id),
|
|
1660
|
+
path TEXT NOT NULL,
|
|
1661
|
+
content BLOB NOT NULL,
|
|
1662
|
+
content_type TEXT,
|
|
1663
|
+
checksum TEXT,
|
|
1664
|
+
size INTEGER,
|
|
1665
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
1666
|
+
UNIQUE(template_id, path)
|
|
1667
|
+
);
|
|
1668
|
+
|
|
1669
|
+
CREATE TABLE IF NOT EXISTS assets (
|
|
1670
|
+
id TEXT PRIMARY KEY,
|
|
1671
|
+
path TEXT NOT NULL UNIQUE,
|
|
1672
|
+
content BLOB NOT NULL,
|
|
1673
|
+
content_type TEXT,
|
|
1674
|
+
size INTEGER,
|
|
1675
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
1676
|
+
);
|
|
1677
|
+
|
|
1678
|
+
CREATE TABLE IF NOT EXISTS render_jobs (
|
|
1679
|
+
id TEXT PRIMARY KEY,
|
|
1680
|
+
template_id TEXT REFERENCES templates(id),
|
|
1681
|
+
status TEXT DEFAULT 'queued',
|
|
1682
|
+
props TEXT, -- JSON
|
|
1683
|
+
format TEXT,
|
|
1684
|
+
output_url TEXT,
|
|
1685
|
+
error TEXT,
|
|
1686
|
+
progress REAL DEFAULT 0,
|
|
1687
|
+
started_at INTEGER,
|
|
1688
|
+
completed_at INTEGER,
|
|
1689
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
1690
|
+
);
|
|
1691
|
+
`);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Template CRUD
|
|
1695
|
+
async createTemplate(data: TemplateInput): Promise<Template> {
|
|
1696
|
+
const id = crypto.randomUUID();
|
|
1697
|
+
this.sql.exec(`
|
|
1698
|
+
INSERT INTO templates (id, name, description, type, meta, private, tags)
|
|
1699
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1700
|
+
`, id, data.name, data.description, data.type, JSON.stringify(data.meta),
|
|
1701
|
+
data.private ? 1 : 0, JSON.stringify(data.tags || []));
|
|
1702
|
+
return this.getTemplate(id);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
async getTemplate(id: string): Promise<Template | null> {
|
|
1706
|
+
const row = this.sql.exec(`SELECT * FROM templates WHERE id = ?`, id).one();
|
|
1707
|
+
return row ? this.parseTemplate(row) : null;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
async listTemplates(): Promise<Template[]> {
|
|
1711
|
+
return this.sql.exec(`SELECT * FROM templates ORDER BY updated_at DESC`)
|
|
1712
|
+
.toArray()
|
|
1713
|
+
.map(row => this.parseTemplate(row));
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// File storage (templates are stored as files, not R2)
|
|
1717
|
+
async saveFile(templateId: string, path: string, content: ArrayBuffer, contentType: string) {
|
|
1718
|
+
const id = crypto.randomUUID();
|
|
1719
|
+
const checksum = await this.hash(content);
|
|
1720
|
+
this.sql.exec(`
|
|
1721
|
+
INSERT OR REPLACE INTO template_files (id, template_id, path, content, content_type, checksum, size)
|
|
1722
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1723
|
+
`, id, templateId, path, content, contentType, checksum, content.byteLength);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
async getFile(templateId: string, path: string): Promise<ArrayBuffer | null> {
|
|
1727
|
+
const row = this.sql.exec(
|
|
1728
|
+
`SELECT content FROM template_files WHERE template_id = ? AND path = ?`,
|
|
1729
|
+
templateId, path
|
|
1730
|
+
).one();
|
|
1731
|
+
return row?.content as ArrayBuffer | null;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
async getTemplateFiles(templateId: string): Promise<FileInfo[]> {
|
|
1735
|
+
return this.sql.exec(
|
|
1736
|
+
`SELECT path, content_type, checksum, size FROM template_files WHERE template_id = ?`,
|
|
1737
|
+
templateId
|
|
1738
|
+
).toArray();
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Render jobs
|
|
1742
|
+
async createJob(templateId: string, props: object, format: string): Promise<string> {
|
|
1743
|
+
const id = crypto.randomUUID();
|
|
1744
|
+
this.sql.exec(`
|
|
1745
|
+
INSERT INTO render_jobs (id, template_id, props, format)
|
|
1746
|
+
VALUES (?, ?, ?, ?)
|
|
1747
|
+
`, id, templateId, JSON.stringify(props), format);
|
|
1748
|
+
return id;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
async updateJobProgress(jobId: string, progress: number) {
|
|
1752
|
+
this.sql.exec(`UPDATE render_jobs SET progress = ? WHERE id = ?`, progress, jobId);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async completeJob(jobId: string, outputUrl: string) {
|
|
1756
|
+
this.sql.exec(`
|
|
1757
|
+
UPDATE render_jobs
|
|
1758
|
+
SET status = 'completed', output_url = ?, progress = 1, completed_at = unixepoch()
|
|
1759
|
+
WHERE id = ?
|
|
1760
|
+
`, outputUrl, jobId);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
private async hash(data: ArrayBuffer): Promise<string> {
|
|
1764
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
1765
|
+
return [...new Uint8Array(hashBuffer)].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
private parseTemplate(row: any): Template {
|
|
1769
|
+
return {
|
|
1770
|
+
...row,
|
|
1771
|
+
meta: JSON.parse(row.meta),
|
|
1772
|
+
tags: JSON.parse(row.tags || '[]'),
|
|
1773
|
+
private: Boolean(row.private),
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
#### Worker Routing to Org DO
|
|
1780
|
+
|
|
1781
|
+
```typescript
|
|
1782
|
+
export default {
|
|
1783
|
+
async fetch(request: Request, env: Env) {
|
|
1784
|
+
const orgId = await getOrgFromAuth(request, env);
|
|
1785
|
+
|
|
1786
|
+
// Get organization's Durable Object
|
|
1787
|
+
const orgDO = env.ORG_STORAGE.get(
|
|
1788
|
+
env.ORG_STORAGE.idFromName(orgId)
|
|
1789
|
+
);
|
|
1790
|
+
|
|
1791
|
+
// Forward request to organization's DO
|
|
1792
|
+
return orgDO.fetch(request);
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
```
|
|
1796
|
+
|
|
469
1797
|
### Deployment Architecture
|
|
470
1798
|
|
|
471
1799
|
```
|
|
@@ -515,11 +1843,12 @@ X-RateLimit-Reset: 1704499200
|
|
|
515
1843
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
516
1844
|
│ │ Data Layer │ │
|
|
517
1845
|
│ │ │ │
|
|
518
|
-
│ │ D1 (
|
|
519
|
-
│ │ • Users
|
|
520
|
-
│ │ •
|
|
521
|
-
│ │ •
|
|
522
|
-
│ │ •
|
|
1846
|
+
│ │ D1 (Global) Org DOs KV R2 Analytics │ │
|
|
1847
|
+
│ │ • Users • Templates • Sessions • Render Engine │ │
|
|
1848
|
+
│ │ • Orgs • Template • Rate cache • Renders│ │
|
|
1849
|
+
│ │ • API keys files limits (images/ • Hits │ │
|
|
1850
|
+
│ │ • Billing • Assets • Font videos) • Stats │ │
|
|
1851
|
+
│ │ • Template index cache │ │
|
|
523
1852
|
│ │ │ │
|
|
524
1853
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
525
1854
|
│ │
|
|
@@ -669,9 +1998,17 @@ database_name = "loopwind"
|
|
|
669
1998
|
binding = "STORAGE"
|
|
670
1999
|
bucket_name = "loopwind-assets"
|
|
671
2000
|
|
|
2001
|
+
[[r2_buckets]]
|
|
2002
|
+
binding = "RENDER_CACHE"
|
|
2003
|
+
bucket_name = "loopwind-renders"
|
|
2004
|
+
|
|
672
2005
|
[[kv_namespaces]]
|
|
673
2006
|
binding = "CACHE"
|
|
674
2007
|
id = "xxx"
|
|
2008
|
+
|
|
2009
|
+
[[analytics_engine_datasets]]
|
|
2010
|
+
binding = "ANALYTICS"
|
|
2011
|
+
dataset = "loopwind_events"
|
|
675
2012
|
```
|
|
676
2013
|
|
|
677
2014
|
### Rendering Decision Flow
|
|
@@ -738,22 +2075,19 @@ loopwind logout # Clear credentials
|
|
|
738
2075
|
loopwind whoami # Show current user
|
|
739
2076
|
|
|
740
2077
|
# Publishing
|
|
741
|
-
loopwind publish [template] # Publish template to
|
|
742
|
-
--
|
|
743
|
-
--private # Private template
|
|
2078
|
+
loopwind publish [template] # Publish template to platform
|
|
2079
|
+
--private # Private template (requires auth to render)
|
|
744
2080
|
--dry-run # Validate only
|
|
745
2081
|
|
|
746
|
-
#
|
|
747
|
-
loopwind render <template> # Render
|
|
748
|
-
--remote # Use cloud API
|
|
2082
|
+
# Local rendering (unchanged)
|
|
2083
|
+
loopwind render <template> # Render locally
|
|
749
2084
|
--props <json> # Template props
|
|
750
2085
|
--format <fmt> # Output format
|
|
751
|
-
--out <path> #
|
|
2086
|
+
--out <path> # Output file
|
|
752
2087
|
|
|
753
|
-
#
|
|
754
|
-
loopwind
|
|
755
|
-
loopwind
|
|
756
|
-
loopwind keys revoke <id> # Revoke API key
|
|
2088
|
+
# Template management
|
|
2089
|
+
loopwind templates # List published templates
|
|
2090
|
+
loopwind unpublish <name> # Remove from platform
|
|
757
2091
|
```
|
|
758
2092
|
|
|
759
2093
|
### Credential Storage
|
|
@@ -828,158 +2162,224 @@ Templates run in isolated environment:
|
|
|
828
2162
|
|
|
829
2163
|
## Database Schema
|
|
830
2164
|
|
|
2165
|
+
### Global D1 Database (shared)
|
|
2166
|
+
|
|
2167
|
+
For data that needs to be queried globally (auth, billing, discovery):
|
|
2168
|
+
|
|
831
2169
|
```sql
|
|
832
2170
|
-- Users
|
|
833
2171
|
CREATE TABLE users (
|
|
834
|
-
id
|
|
835
|
-
email
|
|
836
|
-
username
|
|
837
|
-
password_hash
|
|
838
|
-
name
|
|
839
|
-
avatar_url
|
|
840
|
-
plan
|
|
841
|
-
created_at
|
|
842
|
-
updated_at
|
|
843
|
-
);
|
|
844
|
-
|
|
845
|
-
-- API Keys
|
|
846
|
-
CREATE TABLE api_keys (
|
|
847
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
848
|
-
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
849
|
-
name VARCHAR(100) NOT NULL,
|
|
850
|
-
key_hash VARCHAR(255) NOT NULL,
|
|
851
|
-
key_prefix VARCHAR(12) NOT NULL, -- lw_live_xxxx for display
|
|
852
|
-
scopes TEXT[] DEFAULT '{}',
|
|
853
|
-
last_used_at TIMESTAMP,
|
|
854
|
-
created_at TIMESTAMP DEFAULT NOW()
|
|
2172
|
+
id TEXT PRIMARY KEY,
|
|
2173
|
+
email TEXT UNIQUE NOT NULL,
|
|
2174
|
+
username TEXT UNIQUE NOT NULL,
|
|
2175
|
+
password_hash TEXT,
|
|
2176
|
+
name TEXT,
|
|
2177
|
+
avatar_url TEXT,
|
|
2178
|
+
plan TEXT DEFAULT 'free',
|
|
2179
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
2180
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
855
2181
|
);
|
|
856
2182
|
|
|
857
|
-
--
|
|
858
|
-
CREATE TABLE
|
|
859
|
-
id
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
meta JSONB NOT NULL,
|
|
866
|
-
private BOOLEAN DEFAULT FALSE,
|
|
867
|
-
tags TEXT[] DEFAULT '{}',
|
|
868
|
-
downloads INTEGER DEFAULT 0,
|
|
869
|
-
renders INTEGER DEFAULT 0,
|
|
870
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
871
|
-
updated_at TIMESTAMP DEFAULT NOW(),
|
|
872
|
-
UNIQUE(user_id, name)
|
|
2183
|
+
-- Organizations
|
|
2184
|
+
CREATE TABLE organizations (
|
|
2185
|
+
id TEXT PRIMARY KEY,
|
|
2186
|
+
name TEXT NOT NULL,
|
|
2187
|
+
slug TEXT UNIQUE NOT NULL,
|
|
2188
|
+
owner_id TEXT NOT NULL REFERENCES users(id),
|
|
2189
|
+
plan TEXT DEFAULT 'free',
|
|
2190
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
873
2191
|
);
|
|
874
2192
|
|
|
875
|
-
--
|
|
876
|
-
CREATE TABLE
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
UNIQUE(template_id, version)
|
|
2193
|
+
-- Organization members
|
|
2194
|
+
CREATE TABLE org_members (
|
|
2195
|
+
org_id TEXT NOT NULL REFERENCES organizations(id),
|
|
2196
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
2197
|
+
role TEXT DEFAULT 'member', -- 'owner' | 'admin' | 'member'
|
|
2198
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
2199
|
+
PRIMARY KEY (org_id, user_id)
|
|
883
2200
|
);
|
|
884
2201
|
|
|
885
|
-
--
|
|
886
|
-
CREATE TABLE
|
|
887
|
-
id
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
started_at TIMESTAMP,
|
|
897
|
-
completed_at TIMESTAMP,
|
|
898
|
-
created_at TIMESTAMP DEFAULT NOW()
|
|
2202
|
+
-- API Keys (scoped to organization)
|
|
2203
|
+
CREATE TABLE api_keys (
|
|
2204
|
+
id TEXT PRIMARY KEY,
|
|
2205
|
+
org_id TEXT NOT NULL REFERENCES organizations(id),
|
|
2206
|
+
created_by TEXT NOT NULL REFERENCES users(id),
|
|
2207
|
+
name TEXT NOT NULL,
|
|
2208
|
+
key_hash TEXT NOT NULL,
|
|
2209
|
+
key_prefix TEXT NOT NULL, -- lw_live_xxxx for display
|
|
2210
|
+
scopes TEXT, -- JSON array
|
|
2211
|
+
last_used_at INTEGER,
|
|
2212
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
899
2213
|
);
|
|
900
2214
|
|
|
901
|
-
-- Usage tracking
|
|
2215
|
+
-- Usage tracking (for billing)
|
|
902
2216
|
CREATE TABLE usage (
|
|
903
|
-
id
|
|
904
|
-
|
|
905
|
-
type
|
|
2217
|
+
id TEXT PRIMARY KEY,
|
|
2218
|
+
org_id TEXT NOT NULL REFERENCES organizations(id),
|
|
2219
|
+
type TEXT NOT NULL, -- 'image_render' | 'video_render' | 'storage_mb'
|
|
906
2220
|
count INTEGER DEFAULT 1,
|
|
907
|
-
month
|
|
908
|
-
UNIQUE(
|
|
2221
|
+
month TEXT NOT NULL, -- '2025-01'
|
|
2222
|
+
UNIQUE(org_id, type, month)
|
|
2223
|
+
);
|
|
2224
|
+
|
|
2225
|
+
-- Render tokens (random IDs for secure URLs)
|
|
2226
|
+
-- Maps random token → org + template for rendering
|
|
2227
|
+
CREATE TABLE render_tokens (
|
|
2228
|
+
token TEXT PRIMARY KEY, -- 'x7k9m2p4' (random, used in URL)
|
|
2229
|
+
org_id TEXT NOT NULL REFERENCES organizations(id),
|
|
2230
|
+
template_id TEXT NOT NULL, -- References template in organization's DO
|
|
2231
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
2232
|
+
revoked_at INTEGER -- Set to revoke token
|
|
909
2233
|
);
|
|
2234
|
+
CREATE INDEX idx_render_tokens_team ON render_tokens(org_id);
|
|
2235
|
+
|
|
2236
|
+
-- Custom domains for render URLs
|
|
2237
|
+
CREATE TABLE custom_domains (
|
|
2238
|
+
domain TEXT PRIMARY KEY, -- 'og.mysite.com'
|
|
2239
|
+
org_id TEXT NOT NULL REFERENCES organizations(id),
|
|
2240
|
+
template_id TEXT, -- NULL = route to all templates via /name
|
|
2241
|
+
verified_at INTEGER, -- NULL = pending verification
|
|
2242
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
2243
|
+
);
|
|
2244
|
+
CREATE INDEX idx_custom_domains_team ON custom_domains(org_id);
|
|
2245
|
+
|
|
2246
|
+
-- Allowed hosts for external images (per organization)
|
|
2247
|
+
CREATE TABLE allowed_hosts (
|
|
2248
|
+
id TEXT PRIMARY KEY,
|
|
2249
|
+
org_id TEXT NOT NULL REFERENCES organizations(id),
|
|
2250
|
+
pattern TEXT NOT NULL, -- 'cdn.example.com' or '*.amazonaws.com'
|
|
2251
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
2252
|
+
UNIQUE(org_id, pattern)
|
|
2253
|
+
);
|
|
2254
|
+
CREATE INDEX idx_allowed_hosts_team ON allowed_hosts(org_id);
|
|
910
2255
|
```
|
|
911
2256
|
|
|
2257
|
+
### KV Caching for Render Tokens
|
|
2258
|
+
|
|
2259
|
+
For fast token lookups, cache render token data in KV:
|
|
2260
|
+
|
|
2261
|
+
```typescript
|
|
2262
|
+
// Render token lookup with KV cache
|
|
2263
|
+
async function lookupRenderToken(token: string, env: Env) {
|
|
2264
|
+
// 1. Check KV cache first (~1ms)
|
|
2265
|
+
const cached = await env.KV.get(`rt:${token}`, 'json');
|
|
2266
|
+
if (cached) {
|
|
2267
|
+
return cached; // { org_id, template_id }
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// 2. Cache miss - query D1 (~5-10ms)
|
|
2271
|
+
const row = await env.DB.prepare(`
|
|
2272
|
+
SELECT org_id, template_id FROM render_tokens
|
|
2273
|
+
WHERE token = ? AND revoked_at IS NULL
|
|
2274
|
+
`).bind(token).first();
|
|
2275
|
+
|
|
2276
|
+
if (!row) return null;
|
|
2277
|
+
|
|
2278
|
+
// 3. Cache for 1 hour
|
|
2279
|
+
await env.KV.put(`rt:${token}`, JSON.stringify(row), {
|
|
2280
|
+
expirationTtl: 3600
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
return row;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// Invalidate on token revocation
|
|
2287
|
+
async function revokeRenderToken(token: string, env: Env) {
|
|
2288
|
+
await env.DB.prepare(`
|
|
2289
|
+
UPDATE render_tokens SET revoked_at = unixepoch() WHERE token = ?
|
|
2290
|
+
`).bind(token).run();
|
|
2291
|
+
|
|
2292
|
+
await env.KV.delete(`rt:${token}`);
|
|
2293
|
+
}
|
|
2294
|
+
```
|
|
2295
|
+
|
|
2296
|
+
### Org Durable Object SQLite (per organization)
|
|
2297
|
+
|
|
2298
|
+
Each organization's templates, files, and jobs are stored in their own DO. See `OrgStorage` class above for schema:
|
|
2299
|
+
|
|
2300
|
+
- `templates` - Template metadata
|
|
2301
|
+
- `template_files` - Template source code and assets (stored as BLOBs)
|
|
2302
|
+
- `assets` - Shared assets (fonts, images)
|
|
2303
|
+
- `render_jobs` - Job queue and history
|
|
2304
|
+
|
|
2305
|
+
**Why split this way?**
|
|
2306
|
+
|
|
2307
|
+
| Data | Storage | Reason |
|
|
2308
|
+
|------|---------|--------|
|
|
2309
|
+
| Users, organizations | D1 | Global auth, cross-org queries |
|
|
2310
|
+
| API keys | D1 | Fast validation at edge |
|
|
2311
|
+
| Render tokens | D1 | Fast lookup: token → org + template |
|
|
2312
|
+
| Billing/usage | D1 | Aggregation for invoicing |
|
|
2313
|
+
| Template source | DO SQLite | Organization isolation, co-located with files |
|
|
2314
|
+
| Template files | DO SQLite | Binary storage with transactional updates |
|
|
2315
|
+
| Render jobs | DO SQLite | Per-org job queue, no cross-org leakage |
|
|
2316
|
+
|
|
912
2317
|
---
|
|
913
2318
|
|
|
914
2319
|
## Implementation Phases
|
|
915
2320
|
|
|
916
2321
|
### Phase 1: Authentication & Publishing
|
|
917
2322
|
- User registration/login
|
|
918
|
-
- API key management
|
|
919
2323
|
- `loopwind login/logout/whoami` commands
|
|
920
2324
|
- `loopwind publish` command
|
|
921
|
-
-
|
|
922
|
-
-
|
|
2325
|
+
- Org Durable Object with SQLite storage
|
|
2326
|
+
- Generate random template IDs for render URLs
|
|
923
2327
|
|
|
924
2328
|
### Phase 2: Image Rendering API
|
|
925
|
-
-
|
|
926
|
-
-
|
|
2329
|
+
- GET `/r/{id}` endpoint for image rendering
|
|
2330
|
+
- Query param parsing for props
|
|
2331
|
+
- Edge caching with Cache API
|
|
927
2332
|
- Rate limiting
|
|
928
2333
|
- Usage tracking
|
|
929
2334
|
|
|
930
2335
|
### Phase 3: Video Rendering & Queue
|
|
931
|
-
- Async video rendering with
|
|
2336
|
+
- Async video rendering with Cloudflare Containers
|
|
2337
|
+
- Job status polling endpoint
|
|
932
2338
|
- Webhook notifications
|
|
933
2339
|
- Progress tracking
|
|
934
|
-
- `loopwind render --remote` for videos
|
|
935
2340
|
|
|
936
|
-
### Phase 4:
|
|
937
|
-
-
|
|
938
|
-
-
|
|
939
|
-
- Download/star tracking
|
|
940
|
-
- loopwind.dev website updates
|
|
941
|
-
|
|
942
|
-
### Phase 5: Billing & Pro Features
|
|
2341
|
+
### Phase 4: Dashboard & Billing
|
|
2342
|
+
- loopwind.dev dashboard for managing templates
|
|
2343
|
+
- View render URLs and usage stats
|
|
943
2344
|
- Stripe integration
|
|
944
|
-
-
|
|
945
|
-
|
|
946
|
-
|
|
2345
|
+
- Private templates (auth required to render)
|
|
2346
|
+
|
|
2347
|
+
### Phase 5: Organization Features
|
|
2348
|
+
- Organization accounts with multiple members
|
|
2349
|
+
- Shared templates within organization
|
|
2350
|
+
- Role-based access control
|
|
947
2351
|
|
|
948
2352
|
---
|
|
949
2353
|
|
|
950
|
-
##
|
|
2354
|
+
## Using Published Templates
|
|
951
2355
|
|
|
952
|
-
|
|
2356
|
+
No SDK required - just use the render URL directly:
|
|
2357
|
+
|
|
2358
|
+
```html
|
|
2359
|
+
<!-- Static HTML -->
|
|
2360
|
+
<img src="https://api.loopwind.dev/r/x7k9m2p4?title=Hello" />
|
|
2361
|
+
```
|
|
953
2362
|
|
|
954
2363
|
```typescript
|
|
955
2364
|
// JavaScript/TypeScript
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
// Render image
|
|
961
|
-
const image = await lw.render('username/og-card', {
|
|
962
|
-
props: { title: 'Hello World' },
|
|
963
|
-
format: 'png'
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
// Render video (async)
|
|
967
|
-
const job = await lw.renderVideo('username/promo', {
|
|
968
|
-
props: { title: 'Launch' },
|
|
969
|
-
format: 'mp4'
|
|
970
|
-
});
|
|
2365
|
+
const url = new URL('https://api.loopwind.dev/r/x7k9m2p4');
|
|
2366
|
+
url.searchParams.set('title', post.title);
|
|
2367
|
+
url.searchParams.set('format', 'webp');
|
|
971
2368
|
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
|
|
2369
|
+
const response = await fetch(url);
|
|
2370
|
+
const image = await response.arrayBuffer();
|
|
2371
|
+
```
|
|
975
2372
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
2373
|
+
```tsx
|
|
2374
|
+
// React/Next.js
|
|
2375
|
+
function OGImage({ title }: { title: string }) {
|
|
2376
|
+
const url = `https://api.loopwind.dev/r/x7k9m2p4?title=${encodeURIComponent(title)}`;
|
|
2377
|
+
return <img src={url} alt={title} />;
|
|
2378
|
+
}
|
|
981
2379
|
```
|
|
982
2380
|
|
|
2381
|
+
For local rendering without the platform, see [SDK.md](./SDK.md).
|
|
2382
|
+
|
|
983
2383
|
---
|
|
984
2384
|
|
|
985
2385
|
## References
|