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.
Files changed (90) hide show
  1. package/app/.astro/types.d.ts +1 -0
  2. package/app/dist/_astro/callback.Ci5gaEfJ.css +1 -0
  3. package/app/dist/auth/callback/index.html +81 -0
  4. package/app/dist/device/index.html +70 -0
  5. package/app/dist/index.html +327 -0
  6. package/app/package-lock.json +9239 -0
  7. package/app/package.json +23 -0
  8. package/app/wrangler.toml +8 -0
  9. package/dist/cli.js +54 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/login.d.ts +5 -0
  12. package/dist/commands/login.d.ts.map +1 -0
  13. package/dist/commands/login.js +60 -0
  14. package/dist/commands/login.js.map +1 -0
  15. package/dist/commands/logout.d.ts +5 -0
  16. package/dist/commands/logout.d.ts.map +1 -0
  17. package/dist/commands/logout.js +15 -0
  18. package/dist/commands/logout.js.map +1 -0
  19. package/dist/commands/publish.d.ts +10 -0
  20. package/dist/commands/publish.d.ts.map +1 -0
  21. package/dist/commands/publish.js +155 -0
  22. package/dist/commands/publish.js.map +1 -0
  23. package/dist/commands/templates.d.ts +5 -0
  24. package/dist/commands/templates.d.ts.map +1 -0
  25. package/dist/commands/templates.js +60 -0
  26. package/dist/commands/templates.js.map +1 -0
  27. package/dist/commands/unpublish.d.ts +5 -0
  28. package/dist/commands/unpublish.d.ts.map +1 -0
  29. package/dist/commands/unpublish.js +54 -0
  30. package/dist/commands/unpublish.js.map +1 -0
  31. package/dist/commands/whoami.d.ts +5 -0
  32. package/dist/commands/whoami.d.ts.map +1 -0
  33. package/dist/commands/whoami.js +30 -0
  34. package/dist/commands/whoami.js.map +1 -0
  35. package/dist/lib/api.d.ts +92 -0
  36. package/dist/lib/api.d.ts.map +1 -0
  37. package/dist/lib/api.js +149 -0
  38. package/dist/lib/api.js.map +1 -0
  39. package/dist/lib/auth.d.ts +41 -0
  40. package/dist/lib/auth.d.ts.map +1 -0
  41. package/dist/lib/auth.js +89 -0
  42. package/dist/lib/auth.js.map +1 -0
  43. package/dist/lib/bundler.d.ts +18 -0
  44. package/dist/lib/bundler.d.ts.map +1 -0
  45. package/dist/lib/bundler.js +105 -0
  46. package/dist/lib/bundler.js.map +1 -0
  47. package/dist/lib/helpers.d.ts +35 -2
  48. package/dist/lib/helpers.d.ts.map +1 -1
  49. package/dist/lib/helpers.js +91 -13
  50. package/dist/lib/helpers.js.map +1 -1
  51. package/dist/lib/utils.d.ts.map +1 -1
  52. package/dist/lib/utils.js +9 -0
  53. package/dist/lib/utils.js.map +1 -1
  54. package/dist/sdk/edge.d.ts +65 -0
  55. package/dist/sdk/edge.d.ts.map +1 -0
  56. package/dist/sdk/edge.js +329 -0
  57. package/dist/sdk/edge.js.map +1 -0
  58. package/dist/sdk/errors.d.ts +64 -0
  59. package/dist/sdk/errors.d.ts.map +1 -0
  60. package/dist/sdk/errors.js +94 -0
  61. package/dist/sdk/errors.js.map +1 -0
  62. package/dist/sdk/index.d.ts +29 -0
  63. package/dist/sdk/index.d.ts.map +1 -0
  64. package/dist/sdk/index.js +30 -0
  65. package/dist/sdk/index.js.map +1 -0
  66. package/dist/sdk/render.d.ts +52 -0
  67. package/dist/sdk/render.d.ts.map +1 -0
  68. package/dist/sdk/render.js +432 -0
  69. package/dist/sdk/render.js.map +1 -0
  70. package/dist/sdk/types.d.ts +185 -0
  71. package/dist/sdk/types.d.ts.map +1 -0
  72. package/dist/sdk/types.js +5 -0
  73. package/dist/sdk/types.js.map +1 -0
  74. package/dist/types/template.d.ts +18 -0
  75. package/dist/types/template.d.ts.map +1 -1
  76. package/package.json +26 -4
  77. package/plans/PLATFORM.md +1637 -237
  78. package/plans/PLATFORM_IMPLEMENTATION.md +1347 -530
  79. package/plans/SDK.md +797 -0
  80. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite +0 -0
  81. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-shm +0 -0
  82. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-wal +0 -0
  83. package/platform/migrations/0001_initial.sql +90 -0
  84. package/platform/package-lock.json +3104 -0
  85. package/platform/package.json +30 -0
  86. package/platform/wrangler.toml +43 -0
  87. package/tests-sdk/createRenderer.test.ts +251 -0
  88. package/tests-sdk/errors.test.ts +230 -0
  89. package/tests-sdk/render.test.ts +241 -0
  90. 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
- - **User accounts** at loopwind.dev with authentication
8
- - **Secure API endpoints** for generating images/videos from published templates
9
- - **Template marketplace** for sharing and discovering templates
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
- loopwind publish / loopwind render --remote
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
- # Token stored in ~/.loopwind/credentials
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": "jwt...",
124
- "refreshToken": "...",
125
- "email": "user@example.com",
126
- "expiresAt": "2025-01-06T..."
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
- # Or use API key directly
130
- $ export LOOPWIND_API_KEY=lw_live_xxxxxxxxxxxxxxxxxxxx
131
- $ loopwind publish my-template
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.ts Bundle template POST files to S3 Update registry
161
- Validate props Include assets Get file URLs Set version
162
- Check deps Compress Store checksums Index for search
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": "tpl_abc123",
317
+ "id": "tpl_x7k9m2p4",
191
318
  "name": "og-card",
192
319
  "version": "1.0.0",
193
- "author": "username",
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; // tpl_abc123
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
- ### Synchronous Rendering (Images)
359
+ After publishing, each template gets a unique render URL. Two ways to render:
248
360
 
249
- For quick image renders (<5 seconds):
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
- POST /api/render
253
- Authorization: Bearer <token>
254
- Content-Type: application/json
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
- "template": "username/og-card", // Template slug or ID
258
- "version": "latest", // Optional, default: latest
259
- "props": {
260
- "title": "Hello World",
261
- "subtitle": "My awesome post"
262
- },
263
- "format": "png", // png, jpg, webp, svg
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
- ### Asynchronous Rendering (Videos)
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
- For longer video renders:
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
- "title": "Product Launch",
286
- "logoUrl": "https://example.com/logo.png"
287
- },
288
- "format": "mp4", // mp4, gif
289
- "webhook": "https://myapp.com/hooks/render-complete" // Optional
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
- 202 {
293
- "jobId": "job_abc123",
294
- "status": "queued",
295
- "estimatedDuration": 15000, // ms
296
- "statusUrl": "/api/render/job_abc123",
297
- "createdAt": "2025-01-05T..."
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
- ### Job Status Polling
839
+ **Dashboard UI (Settings → Signing Keys):**
302
840
 
303
841
  ```
304
- GET /api/render/:jobId
305
- Authorization: Bearer <token>
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
- 200 {
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": "processing", // queued, processing, completed, failed
310
- "progress": 0.45, // 0-1 for video
311
- "currentFrame": 45,
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...", // URL expires in 24h
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 Payload
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
- "status": "completed",
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 Remote Rendering
1204
+ ### CLI Output After Publish
349
1205
 
350
1206
  ```bash
351
- # Render using cloud API (fast, no local compute)
352
- $ loopwind render my-template --remote --props '{"title": "Hello"}'
353
- Rendered in 234ms
354
- Output: https://cdn.loopwind.dev/renders/rnd_xyz789.png
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
- # For videos (async)
357
- $ loopwind render promo-video --remote --format mp4 --props '{"title": "Launch"}'
358
- Rendering video... 45% (45/100 frames)
359
- ✓ Rendered in 12.5s
360
- Output: https://cdn.loopwind.dev/renders/job_abc123.mp4
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
- - Team accounts
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, session management |
460
- | Database | D1 (SQLite) / Turso | Users, templates, jobs - edge-native |
461
- | Cache | Workers KV | Sessions, rate limits, template cache |
462
- | Queue | Durable Objects | Video job orchestration |
463
- | Storage | R2 | Templates, rendered outputs |
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 (SQLite) KV R2 │ │
519
- │ │ • Users • Sessions Templates │ │
520
- │ │ • Templates • Rate limits Rendered videos │ │
521
- │ │ • Jobs • Template cache Assets │ │
522
- │ │ • Usage • Font cache │ │
1846
+ │ │ D1 (Global) Org DOs KV R2 Analytics │ │
1847
+ │ │ • Users Templates • Sessions Render Engine │ │
1848
+ │ │ • OrgsTemplate • 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 registry
742
- --version <ver> # Semantic version
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
- # Remote rendering
747
- loopwind render <template> # Render (local by default)
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> # Save locally
2086
+ --out <path> # Output file
752
2087
 
753
- # Account management
754
- loopwind keys list # List API keys
755
- loopwind keys create # Create new API key
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 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
835
- email VARCHAR(255) UNIQUE NOT NULL,
836
- username VARCHAR(50) UNIQUE NOT NULL,
837
- password_hash VARCHAR(255),
838
- name VARCHAR(255),
839
- avatar_url VARCHAR(500),
840
- plan VARCHAR(20) DEFAULT 'free',
841
- created_at TIMESTAMP DEFAULT NOW(),
842
- updated_at TIMESTAMP DEFAULT NOW()
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
- -- Templates
858
- CREATE TABLE templates (
859
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
860
- user_id UUID REFERENCES users(id) ON DELETE CASCADE,
861
- name VARCHAR(100) NOT NULL,
862
- slug VARCHAR(150) NOT NULL, -- username/template-name
863
- description TEXT,
864
- type VARCHAR(20) NOT NULL, -- image, video
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
- -- Template Versions
876
- CREATE TABLE template_versions (
877
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
878
- template_id UUID REFERENCES templates(id) ON DELETE CASCADE,
879
- version VARCHAR(20) NOT NULL,
880
- files JSONB NOT NULL, -- [{path, url, checksum}]
881
- published_at TIMESTAMP DEFAULT NOW(),
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
- -- Render Jobs
886
- CREATE TABLE render_jobs (
887
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
888
- user_id UUID REFERENCES users(id),
889
- template_id UUID REFERENCES templates(id),
890
- status VARCHAR(20) DEFAULT 'queued',
891
- props JSONB,
892
- format VARCHAR(10),
893
- output_url VARCHAR(500),
894
- error TEXT,
895
- progress FLOAT DEFAULT 0,
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 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
904
- user_id UUID REFERENCES users(id),
905
- type VARCHAR(20) NOT NULL, -- image_render, video_render, publish
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 DATE NOT NULL, -- First of month
908
- UNIQUE(user_id, type, month)
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
- - Template storage in R2
922
- - Basic registry API
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
- - Synchronous image rendering endpoint
926
- - `loopwind render --remote` for images
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 job queue
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: Marketplace & Discovery
937
- - Template search and browse
938
- - User profiles
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
- - Usage-based billing
945
- - Private templates
946
- - Team accounts
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
- ## API Client SDK
2354
+ ## Using Published Templates
951
2355
 
952
- For easy integration, provide official SDKs:
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
- import { Loopwind } from '@loopwind/sdk';
957
-
958
- const lw = new Loopwind({ apiKey: 'lw_live_xxx' });
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
- // Poll for completion
973
- const result = await job.wait();
974
- console.log(result.outputUrl);
2369
+ const response = await fetch(url);
2370
+ const image = await response.arrayBuffer();
2371
+ ```
975
2372
 
976
- // Or use webhook
977
- await lw.renderVideo('username/promo', {
978
- props: { title: 'Launch' },
979
- webhook: 'https://myapp.com/hooks/render'
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