@open-assets/open-assets 0.1.2

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/lib/server.mjs ADDED
@@ -0,0 +1,283 @@
1
+ import { createServer } from "http";
2
+ import { readFileSync, existsSync, statSync, watch } from "fs";
3
+ import { resolve, join, extname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { renderScreenshot, runXcodeOutput, closeBrowser } from "./renderer.mjs";
6
+
7
+ const __dirname = resolve(fileURLToPath(import.meta.url), "..");
8
+ const VIEWER_PATH = join(__dirname, "..", "public", "viewer.html");
9
+
10
+ const MIME_TYPES = {
11
+ ".html": "text/html",
12
+ ".css": "text/css",
13
+ ".js": "application/javascript",
14
+ ".mjs": "application/javascript",
15
+ ".json": "application/json",
16
+ ".png": "image/png",
17
+ ".jpg": "image/jpeg",
18
+ ".jpeg": "image/jpeg",
19
+ ".gif": "image/gif",
20
+ ".svg": "image/svg+xml",
21
+ ".ico": "image/x-icon",
22
+ ".woff": "font/woff",
23
+ ".woff2": "font/woff2",
24
+ ".ttf": "font/ttf",
25
+ ".otf": "font/otf",
26
+ ".webp": "image/webp",
27
+ ".mp4": "video/mp4",
28
+ ".webm": "video/webm",
29
+ };
30
+
31
+ function getMime(filePath) {
32
+ return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
33
+ }
34
+
35
+ function serveFile(res, filePath) {
36
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
37
+ res.writeHead(404, { "Content-Type": "text/plain" });
38
+ res.end("Not found");
39
+ return;
40
+ }
41
+ const mime = getMime(filePath);
42
+ const content = readFileSync(filePath);
43
+ res.writeHead(200, { "Content-Type": mime });
44
+ res.end(content);
45
+ }
46
+
47
+ function readBody(req) {
48
+ return new Promise((resolve) => {
49
+ let body = "";
50
+ req.on("data", (chunk) => (body += chunk));
51
+ req.on("end", () => resolve(body));
52
+ });
53
+ }
54
+
55
+ export function startServer(projectDir, options = {}) {
56
+ const port = options.port || 3200;
57
+ const host = options.host || "localhost";
58
+ const quiet = options.quiet || false;
59
+ const manifestPath = options.manifestPath || resolve(projectDir, "manifest.json");
60
+ const staticDirs = (options.staticDirs || []).map((d) => resolve(d));
61
+
62
+ const log = quiet ? () => {} : console.log.bind(console);
63
+
64
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
65
+
66
+ // Auto-serve publicDir from manifest
67
+ if (manifest.publicDir) {
68
+ const publicDirPath = resolve(projectDir, manifest.publicDir);
69
+ if (existsSync(publicDirPath) && statSync(publicDirPath).isDirectory()) {
70
+ staticDirs.unshift(publicDirPath);
71
+ }
72
+ }
73
+
74
+ // ---- Live reload via SSE ----
75
+ const sseClients = new Set();
76
+ const watchers = [];
77
+
78
+ function notifyClients(changedFile) {
79
+ const data = JSON.stringify({ file: changedFile });
80
+ for (const client of sseClients) {
81
+ client.write(`data: ${data}\n\n`);
82
+ }
83
+ }
84
+
85
+ function watchDir(dir) {
86
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
87
+ try {
88
+ const watcher = watch(dir, { recursive: true }, (eventType, filename) => {
89
+ if (!filename) return;
90
+ const relPath = filename.replace(/\\/g, "/");
91
+ log(`[reload] ${relPath} changed`);
92
+ notifyClients(relPath);
93
+ });
94
+ watchers.push(watcher);
95
+ } catch (err) {
96
+ // fs.watch recursive may not be supported on all platforms
97
+ console.warn(`Warning: could not watch ${dir}: ${err.message}`);
98
+ }
99
+ }
100
+
101
+ // Watch public dir
102
+ for (const dir of staticDirs) {
103
+ watchDir(dir);
104
+ }
105
+
106
+ // Watch asset files (HTML/SVG templates referenced in manifest)
107
+ const assetDirs = new Set();
108
+ for (const col of manifest.collections || []) {
109
+ for (const template of col.templates || []) {
110
+ const templatePath = resolve(projectDir, template.src);
111
+ const templateDir = resolve(templatePath, "..");
112
+ assetDirs.add(templateDir);
113
+ }
114
+ }
115
+ for (const dir of assetDirs) {
116
+ if (!staticDirs.includes(dir)) {
117
+ watchDir(dir);
118
+ }
119
+ }
120
+
121
+ const server = createServer(async (req, res) => {
122
+ const url = new URL(req.url, `http://${host}:${port}`);
123
+ const pathname = url.pathname;
124
+
125
+ // CORS headers for API routes
126
+ if (pathname.startsWith("/api/")) {
127
+ res.setHeader("Access-Control-Allow-Origin", "*");
128
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
129
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
130
+ if (req.method === "OPTIONS") {
131
+ res.writeHead(204);
132
+ return res.end();
133
+ }
134
+ }
135
+
136
+ // API: render screenshot (unified for all collection types)
137
+ if (req.method === "POST" && pathname === "/api/render-screenshot") {
138
+ try {
139
+ const { file, width, height, sourceW, sourceH } = JSON.parse(
140
+ await readBody(req)
141
+ );
142
+ if (!file || !width || !height) {
143
+ res.writeHead(400, { "Content-Type": "application/json" });
144
+ return res.end(
145
+ JSON.stringify({ ok: false, error: "Missing file, width, or height" })
146
+ );
147
+ }
148
+ const sw = sourceW || width;
149
+ const sh = sourceH || height;
150
+ log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
151
+ const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh);
152
+ res.writeHead(200, {
153
+ "Content-Type": "image/png",
154
+ "Content-Length": buffer.length,
155
+ });
156
+ res.end(buffer);
157
+ } catch (err) {
158
+ console.error(err);
159
+ res.writeHead(500, { "Content-Type": "application/json" });
160
+ res.end(JSON.stringify({ ok: false, error: err.message }));
161
+ }
162
+ return;
163
+ }
164
+
165
+ // API: run xcode output for a collection
166
+ if (req.method === "POST" && pathname === "/api/run-output") {
167
+ try {
168
+ const { collectionId, outputIndex } = JSON.parse(await readBody(req));
169
+ const col = manifest.collections.find((c) => c.id === collectionId);
170
+ if (!col) {
171
+ res.writeHead(404, { "Content-Type": "application/json" });
172
+ return res.end(JSON.stringify({ ok: false, error: "Collection not found" }));
173
+ }
174
+ const output = col.outputs?.[outputIndex || 0];
175
+ if (!output) {
176
+ res.writeHead(404, { "Content-Type": "application/json" });
177
+ return res.end(JSON.stringify({ ok: false, error: "Output not found" }));
178
+ }
179
+ if (output.type === "xcode") {
180
+ const path = await runXcodeOutput(projectDir, col, output);
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(JSON.stringify({ ok: true, path }));
183
+ } else {
184
+ res.writeHead(400, { "Content-Type": "application/json" });
185
+ res.end(JSON.stringify({ ok: false, error: `Unsupported output type: ${output.type}` }));
186
+ }
187
+ } catch (err) {
188
+ console.error(err);
189
+ res.writeHead(500, { "Content-Type": "application/json" });
190
+ res.end(JSON.stringify({ ok: false, error: err.message }));
191
+ }
192
+ return;
193
+ }
194
+
195
+ // SSE: live reload stream
196
+ if (req.method === "GET" && pathname === "/api/live-reload") {
197
+ res.writeHead(200, {
198
+ "Content-Type": "text/event-stream",
199
+ "Cache-Control": "no-cache",
200
+ Connection: "keep-alive",
201
+ "Access-Control-Allow-Origin": "*",
202
+ });
203
+ res.write(":\n\n"); // heartbeat comment to establish connection
204
+ sseClients.add(res);
205
+ req.on("close", () => sseClients.delete(res));
206
+ return;
207
+ }
208
+
209
+ // GET / → serve viewer
210
+ if (req.method === "GET" && pathname === "/") {
211
+ serveFile(res, VIEWER_PATH);
212
+ return;
213
+ }
214
+
215
+ // GET static files → try additional static dirs first, then project dir
216
+ if (req.method === "GET") {
217
+ const safePath = pathname.replace(/\.\./g, "");
218
+
219
+ // Check additional static directories
220
+ for (const dir of staticDirs) {
221
+ const filePath = join(dir, safePath);
222
+ if (existsSync(filePath) && statSync(filePath).isFile()) {
223
+ serveFile(res, filePath);
224
+ return;
225
+ }
226
+ }
227
+
228
+ // Fall back to project directory
229
+ const filePath = join(projectDir, safePath);
230
+ serveFile(res, filePath);
231
+ return;
232
+ }
233
+
234
+ res.writeHead(404);
235
+ res.end();
236
+ });
237
+
238
+ server.listen(port, host, () => {
239
+ log();
240
+ log(` open-assets dev server`);
241
+ log(` ───────────────────────`);
242
+ log(` Local: http://${host}:${port}`);
243
+ if (host === "0.0.0.0") {
244
+ log(` Network: http://<your-ip>:${port}`);
245
+ }
246
+ log(` Project: ${projectDir}`);
247
+ if (staticDirs.length > 0) {
248
+ log(` Static: ${staticDirs.join(", ")}`);
249
+ }
250
+ log();
251
+ });
252
+
253
+ server.on("error", (err) => {
254
+ if (err.code === "EADDRINUSE") {
255
+ console.error(`Error: Port ${port} is already in use.`);
256
+ console.error(`Try a different port: open-assets dev --port ${port + 1}`);
257
+ process.exit(1);
258
+ }
259
+ throw err;
260
+ });
261
+
262
+ function cleanup() {
263
+ for (const w of watchers) w.close();
264
+ for (const client of sseClients) client.end();
265
+ sseClients.clear();
266
+ }
267
+
268
+ process.on("SIGINT", async () => {
269
+ cleanup();
270
+ await closeBrowser();
271
+ server.close();
272
+ process.exit(0);
273
+ });
274
+
275
+ process.on("SIGTERM", async () => {
276
+ cleanup();
277
+ await closeBrowser();
278
+ server.close();
279
+ process.exit(0);
280
+ });
281
+
282
+ return server;
283
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@open-assets/open-assets",
3
+ "version": "0.1.2",
4
+ "description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
5
+ "type": "module",
6
+ "bin": {
7
+ "open-assets": "./bin/open-assets.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "public/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "keywords": [
20
+ "screenshots",
21
+ "app-store",
22
+ "assets",
23
+ "icons",
24
+ "logos",
25
+ "marketing",
26
+ "storybook",
27
+ "puppeteer",
28
+ "open",
29
+ "export",
30
+ "xcode"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/ianmaccallum/open-assets"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "homepage": "https://openassets.dev",
41
+ "dependencies": {
42
+ "commander": "^13.0.0",
43
+ "open": "^10.0.0",
44
+ "puppeteer": "^24.37.5"
45
+ }
46
+ }