@liqvid/studio 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +9 -0
  2. package/dist/esm/LiqvidDevToolsProvider.js +49 -0
  3. package/dist/esm/api/contract.mjs +48 -0
  4. package/dist/esm/api/project-meta.mjs +33 -0
  5. package/dist/esm/api/recording.mjs +156 -0
  6. package/dist/esm/api/root.mjs +4 -0
  7. package/dist/esm/api/static-file.mjs +82 -0
  8. package/dist/esm/api/types.mjs +1 -0
  9. package/dist/esm/client.mjs +98 -0
  10. package/dist/esm/conventions.mjs +3 -0
  11. package/dist/esm/index.mjs +20 -0
  12. package/dist/esm/initialize.mjs +50 -0
  13. package/dist/esm/jobs/watch-assets.mjs +99 -0
  14. package/dist/esm/jobs/watch-project-files.mjs +216 -0
  15. package/dist/esm/next/api.mjs +48 -0
  16. package/dist/esm/next/page.js +15 -0
  17. package/dist/esm/pages/NewProjectButton.js +62 -0
  18. package/dist/esm/pages/RebuildButton.js +24 -0
  19. package/dist/esm/pages/root-actions.js +151 -0
  20. package/dist/esm/pages/root.js +25 -0
  21. package/dist/esm/pages/root.module.css +326 -0
  22. package/dist/esm/palette.css +279 -0
  23. package/dist/esm/providers/hosting/github-pages.mjs +10 -0
  24. package/dist/esm/providers/hosting/liqvid-studio.mjs +8 -0
  25. package/dist/esm/providers/hosting/s3.mjs +9 -0
  26. package/dist/esm/providers/hosting/sftp.mjs +22 -0
  27. package/dist/esm/providers/index.mjs +10 -0
  28. package/dist/esm/providers/social/bluesky.mjs +8 -0
  29. package/dist/esm/providers/social/facebook.mjs +8 -0
  30. package/dist/esm/providers/social/instagram.mjs +8 -0
  31. package/dist/esm/providers/social/twitter.mjs +7 -0
  32. package/dist/esm/providers/social/youtube.mjs +7 -0
  33. package/dist/esm/providers/types.mjs +1 -0
  34. package/dist/esm/publish.mjs +37 -0
  35. package/dist/esm/recording/RecordingControl.js +110 -0
  36. package/dist/esm/recording/RecordingControl.module.css +0 -0
  37. package/dist/esm/recording/RecordingDialog.js +114 -0
  38. package/dist/esm/recording/RecordingDialog.module.css +194 -0
  39. package/dist/esm/schemas/liqvid-config.mjs +32 -0
  40. package/dist/esm/schemas/project.mjs +27 -0
  41. package/dist/esm/schemas/recording-meta.mjs +11 -0
  42. package/dist/esm/types/assets.mjs +1 -0
  43. package/dist/esm/types.mjs +12 -0
  44. package/dist/esm/ui/Dialog.js +71 -0
  45. package/dist/esm/ui/DockableDialog.js +131 -0
  46. package/dist/esm/ui/DockableDialog.module.css +63 -0
  47. package/dist/esm/ui/RadioTabs.js +13 -0
  48. package/dist/esm/ui/RadioTabs.module.css +54 -0
  49. package/dist/esm/ui/Tabs.js +29 -0
  50. package/dist/esm/ui/Tabs.module.css +31 -0
  51. package/dist/esm/ui/Toast.js +64 -0
  52. package/dist/esm/ui/Toast.module.css +50 -0
  53. package/dist/esm/ui/Toaster.js +13 -0
  54. package/dist/esm/ui/Toaster.module.css +9 -0
  55. package/dist/esm/ui/test.js +14 -0
  56. package/dist/esm/utils/dom.mjs +6 -0
  57. package/dist/esm/utils/fs.mjs +94 -0
  58. package/dist/esm/utils/misc.mjs +15 -0
  59. package/dist/esm/utils/react.mjs +8 -0
  60. package/dist/esm/utils/rsync.mjs +57 -0
  61. package/dist/templates/project.json.hbs +5 -0
  62. package/dist/templates/projects/code/page.tsx.hbs +23 -0
  63. package/dist/templates/projects/code/src/assets.ts.hbs +9 -0
  64. package/dist/templates/projects/code/src/client.tsx.hbs +22 -0
  65. package/dist/templates/projects/code/src/helpers.ts.hbs +21 -0
  66. package/dist/templates/projects/code/src/highlights.ts.hbs +3 -0
  67. package/dist/templates/projects/code/src/markers.ts.hbs +13 -0
  68. package/dist/templates/projects/code/src/project.ts.hbs +23 -0
  69. package/dist/templates/projects/code/template.json +3 -0
  70. package/dist/templates/projects/default/page.tsx.hbs +23 -0
  71. package/dist/templates/projects/default/src/assets.ts.hbs +9 -0
  72. package/dist/templates/projects/default/src/client.tsx.hbs +22 -0
  73. package/dist/templates/projects/default/src/helpers.ts.hbs +21 -0
  74. package/dist/templates/projects/default/src/highlights.ts.hbs +3 -0
  75. package/dist/templates/projects/default/src/markers.ts.hbs +13 -0
  76. package/dist/templates/projects/default/src/project.ts.hbs +23 -0
  77. package/dist/templates/projects/default/template.json +4 -0
  78. package/dist/templates/types.ts.hbs +20 -0
  79. package/dist/types/LiqvidDevToolsProvider.d.ts +12 -0
  80. package/dist/types/api/contract.d.mts +66 -0
  81. package/dist/types/api/project-meta.d.mts +1 -0
  82. package/dist/types/api/recording.d.mts +5 -0
  83. package/dist/types/api/root.d.mts +1 -0
  84. package/dist/types/api/static-file.d.mts +6 -0
  85. package/dist/types/api/types.d.mts +1 -0
  86. package/dist/types/client.d.mts +43 -0
  87. package/dist/types/conventions.d.mts +3 -0
  88. package/dist/types/index.d.mts +19 -0
  89. package/dist/types/initialize.d.mts +14 -0
  90. package/dist/types/jobs/watch-assets.d.mts +18 -0
  91. package/dist/types/jobs/watch-project-files.d.mts +4 -0
  92. package/dist/types/next/api.d.mts +14 -0
  93. package/dist/types/next/page.d.ts +4 -0
  94. package/dist/types/pages/NewProjectButton.d.ts +1 -0
  95. package/dist/types/pages/RebuildButton.d.ts +1 -0
  96. package/dist/types/pages/root-actions.d.ts +29 -0
  97. package/dist/types/pages/root.d.ts +1 -0
  98. package/dist/types/providers/hosting/github-pages.d.mts +11 -0
  99. package/dist/types/providers/hosting/liqvid-studio.d.mts +10 -0
  100. package/dist/types/providers/hosting/s3.d.mts +11 -0
  101. package/dist/types/providers/hosting/sftp.d.mts +13 -0
  102. package/dist/types/providers/index.d.mts +10 -0
  103. package/dist/types/providers/social/bluesky.d.mts +10 -0
  104. package/dist/types/providers/social/facebook.d.mts +10 -0
  105. package/dist/types/providers/social/instagram.d.mts +10 -0
  106. package/dist/types/providers/social/twitter.d.mts +9 -0
  107. package/dist/types/providers/social/youtube.d.mts +9 -0
  108. package/dist/types/providers/types.d.mts +9 -0
  109. package/dist/types/publish.d.mts +1 -0
  110. package/dist/types/recording/RecordingControl.d.ts +16 -0
  111. package/dist/types/recording/RecordingDialog.d.ts +10 -0
  112. package/dist/types/schemas/liqvid-config.d.mts +51 -0
  113. package/dist/types/schemas/project.d.mts +51 -0
  114. package/dist/types/schemas/recording-meta.d.mts +17 -0
  115. package/dist/types/types/assets.d.mts +3 -0
  116. package/dist/types/types.d.mts +20 -0
  117. package/dist/types/ui/Dialog.d.ts +31 -0
  118. package/dist/types/ui/DockableDialog.d.ts +25 -0
  119. package/dist/types/ui/RadioTabs.d.ts +18 -0
  120. package/dist/types/ui/Tabs.d.ts +9 -0
  121. package/dist/types/ui/Toast.d.ts +23 -0
  122. package/dist/types/ui/Toaster.d.ts +6 -0
  123. package/dist/types/ui/test.d.ts +9 -0
  124. package/dist/types/utils/dom.d.mts +3 -0
  125. package/dist/types/utils/fs.d.mts +32 -0
  126. package/dist/types/utils/misc.d.mts +4 -0
  127. package/dist/types/utils/react.d.mts +5 -0
  128. package/dist/types/utils/rsync.d.mts +10 -0
  129. package/package.json +94 -0
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) Yuri Sulyma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Duration } from "@liqvid/duration";
4
+ import { RecordingProvider } from "@liqvid/recording";
5
+ import { LiqvidStudioPluginApiProvider, } from "@liqvid/studio-plugin-api";
6
+ import { createContext, useContext, useMemo, useState } from "react";
7
+ import { setProjectMeta } from "./client.mjs";
8
+ import { Toaster } from "./ui/Toaster";
9
+ import "./palette.css";
10
+ export const ProjectContext = createContext({
11
+ projectPath: "",
12
+ });
13
+ export function useProjectContext() {
14
+ return useContext(ProjectContext);
15
+ }
16
+ export function LiqvidDevToolsProvider({ children, plugins, projectPath, }) {
17
+ const projectContext = useMemo(() => ({ projectPath }), [projectPath]);
18
+ const [toasts, setToasts] = useState([]);
19
+ const api = useMemo(() => ({
20
+ makeToast(toast) {
21
+ setToasts((prev) => [...prev, { ...toast, time: Date.now() }]);
22
+ },
23
+ setDuration: (duration) => {
24
+ setProjectMeta({
25
+ body: {
26
+ durationMs: Duration.from(duration).inMilliseconds(),
27
+ },
28
+ search: {
29
+ url: projectPath,
30
+ },
31
+ });
32
+ },
33
+ }), [projectPath]);
34
+ const recordingPlugins = useMemo(() => (plugins ?? []).reduce((acc, plugin) => {
35
+ if ("recorder" in plugin) {
36
+ acc.push(plugin);
37
+ }
38
+ return acc;
39
+ }, []), [plugins]);
40
+ return (_jsx(LiqvidStudioPluginApiProvider, { plugins: plugins, value: api, children: _jsxs(RecordingProvider, { plugins: recordingPlugins, children: [_jsxs(ProjectContext.Provider, { value: projectContext, children: [plugins?.map((plugin) => {
41
+ if (!plugin.useConfigurePlugin)
42
+ return null;
43
+ return (_jsx(CallHook, { usePlugin: plugin.useConfigurePlugin }, plugin.package));
44
+ }), children] }), _jsx(Toaster, { toasts })] }) }));
45
+ }
46
+ function CallHook({ usePlugin }) {
47
+ usePlugin();
48
+ return null;
49
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+ import { RecordingMeta } from "../schemas/recording-meta.mjs";
3
+ export const listRecordingsOperation = {
4
+ endpoint: "/recordings",
5
+ response: z.array(RecordingMeta),
6
+ search: z.object({
7
+ url: z.string(),
8
+ }),
9
+ };
10
+ export const setProjectMetaOperation = {
11
+ body: z.object({
12
+ durationMs: z.number(),
13
+ }),
14
+ endpoint: "/project-meta",
15
+ method: "POST",
16
+ search: z.object({
17
+ url: z.string(),
18
+ }),
19
+ };
20
+ /**
21
+ * Schema for plugin recording data.
22
+ * Data can be a Blob (for media) which requires a filename,
23
+ * or any JSON-serializable data.
24
+ */
25
+ export const PluginRecordingData = z.object({
26
+ data: z.unknown(),
27
+ /** For Blob data, the filename to save as */
28
+ filename: z.string().optional(),
29
+ key: z.string(),
30
+ });
31
+ export const saveRecordingOperation = {
32
+ endpoint: "/recordings",
33
+ method: "POST",
34
+ search: z.object({
35
+ url: z.string(),
36
+ }),
37
+ };
38
+ /**
39
+ * Serve static files from the app directory.
40
+ * The `url` param is the path relative to the app directory.
41
+ * Example: /api/liqvid/static?url=/projects/my-video/.liqvid/recordings/test/@liqvid.media/audio.webm
42
+ */
43
+ export const staticFileOperation = {
44
+ endpoint: "/static",
45
+ search: z.object({
46
+ url: z.string(),
47
+ }),
48
+ };
@@ -0,0 +1,33 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { safeGet } from "have-fun";
6
+ import { StatusCodes } from "http-status-codes";
7
+ import { PROJECT_META_FILE } from "../conventions.mjs";
8
+ import { setProjectMetaOperation } from "./contract.mjs";
9
+ export async function setProjectMeta(searchParams, rawBody) {
10
+ const $url = safeGet(searchParams, "url");
11
+ if ($url.isNone) {
12
+ return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
13
+ }
14
+ const $body = setProjectMetaOperation.body.safeParse(rawBody);
15
+ if (!$body.success) {
16
+ return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
17
+ }
18
+ const body = $body.data;
19
+ let projectPath = fileURLToPath($url.unwrap());
20
+ if (projectPath.endsWith("page.tsx")) {
21
+ projectPath = path.dirname(projectPath);
22
+ }
23
+ const assetsDir = path.join(projectPath, ".liqvid");
24
+ const projectMetaFile = path.join(assetsDir, PROJECT_META_FILE);
25
+ // error if assets dir doesn't exist
26
+ if (!fs.existsSync(assetsDir)) {
27
+ return Response.json(null, { status: StatusCodes.INTERNAL_SERVER_ERROR });
28
+ }
29
+ await fsp.writeFile(projectMetaFile, JSON.stringify({ duration: { milliseconds: body.durationMs } }, null, 2));
30
+ return new Response(null, {
31
+ status: StatusCodes.NO_CONTENT,
32
+ });
33
+ }
@@ -0,0 +1,156 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { compare } from "@liqvid/utils";
6
+ import { safeGet } from "have-fun";
7
+ import { StatusCodes } from "http-status-codes";
8
+ import { RECORDING_META_FILE } from "../conventions.mjs";
9
+ import { RecordingMetaFile as RecordingMetaFileSchema } from "../schemas/recording-meta.mjs";
10
+ import { loadJson } from "../utils/fs.mjs";
11
+ export async function listRecordings(searchParams) {
12
+ const $url = safeGet(searchParams, "url");
13
+ if ($url.isNone) {
14
+ return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
15
+ }
16
+ let projectDir = fileURLToPath($url.unwrap());
17
+ if (projectDir.endsWith("page.tsx")) {
18
+ projectDir = path.dirname(projectDir);
19
+ }
20
+ const assetsDir = path.join(projectDir, ".liqvid");
21
+ // error if assets dir doesn't exist
22
+ if (!fs.existsSync(assetsDir)) {
23
+ return Response.json(null, { status: StatusCodes.INTERNAL_SERVER_ERROR });
24
+ }
25
+ const recordingsDir = path.join(assetsDir, "recordings");
26
+ if (!fs.existsSync(recordingsDir)) {
27
+ return Response.json([]);
28
+ }
29
+ const recordingNames = await fsp.readdir(recordingsDir);
30
+ const $recordings = await Promise.all(recordingNames.map(async (name) => {
31
+ const dir = path.join(recordingsDir, name);
32
+ const recordingMeta = await loadJson(RecordingMetaFileSchema, path.join(dir, RECORDING_META_FILE));
33
+ const children = await fsp.readdir(dir);
34
+ return recordingMeta.map((file) => ({
35
+ ...file,
36
+ name,
37
+ plugins: children.filter((x) => x !== RECORDING_META_FILE),
38
+ }));
39
+ }));
40
+ const recordings = $recordings.reduce((acc, $curr) => {
41
+ if ($curr.isErr)
42
+ return acc;
43
+ acc.push($curr.unwrap());
44
+ return acc;
45
+ }, []);
46
+ recordings.sort((a, b) => compare(a.created, b.created));
47
+ return Response.json(recordings);
48
+ }
49
+ /**
50
+ * Convert package name to directory name (replace / with .)
51
+ */
52
+ function packageToDir(packageName) {
53
+ return packageName.replace(/\//g, ".");
54
+ }
55
+ /**
56
+ * Save a new recording to disk.
57
+ */
58
+ export async function saveRecording(searchParams, formData) {
59
+ const $url = safeGet(searchParams, "url");
60
+ if ($url.isNone) {
61
+ return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
62
+ }
63
+ // Parse metadata
64
+ const metadataStr = formData.get("metadata");
65
+ if (typeof metadataStr !== "string") {
66
+ return Response.json({ error: "missing metadata" }, { status: StatusCodes.BAD_REQUEST });
67
+ }
68
+ let metadata;
69
+ try {
70
+ metadata = JSON.parse(metadataStr);
71
+ }
72
+ catch {
73
+ return Response.json({ error: "invalid metadata" }, { status: StatusCodes.BAD_REQUEST });
74
+ }
75
+ let projectDir = fileURLToPath($url.unwrap());
76
+ if (projectDir.endsWith("page.tsx")) {
77
+ projectDir = path.dirname(projectDir);
78
+ }
79
+ const assetsDir = path.join(projectDir, ".liqvid");
80
+ // Create assets dir if it doesn't exist
81
+ if (!fs.existsSync(assetsDir)) {
82
+ await fsp.mkdir(assetsDir, { recursive: true });
83
+ }
84
+ const recordingsDir = path.join(assetsDir, "recordings");
85
+ if (!fs.existsSync(recordingsDir)) {
86
+ await fsp.mkdir(recordingsDir, { recursive: true });
87
+ }
88
+ // Create recording directory with ISO datetime name
89
+ const recordingName = new Date().toISOString().replace(/[:.]/g, "-");
90
+ const recordingDir = path.join(recordingsDir, recordingName);
91
+ await fsp.mkdir(recordingDir, { recursive: true });
92
+ // Write recording-meta.json
93
+ const recordingMeta = {
94
+ created: new Date().toISOString(),
95
+ duration: {
96
+ milliseconds: metadata.durationMs,
97
+ },
98
+ };
99
+ await fsp.writeFile(path.join(recordingDir, RECORDING_META_FILE), JSON.stringify(recordingMeta, null, "\t"));
100
+ // Write plugin data
101
+ for (const pluginInfo of metadata.plugins) {
102
+ const pluginDir = path.join(recordingDir, packageToDir(pluginInfo.key));
103
+ await fsp.mkdir(pluginDir, { recursive: true });
104
+ const data = formData.get(pluginInfo.key);
105
+ if (data === null)
106
+ continue;
107
+ if (pluginInfo.isBlob) {
108
+ // Write blob data with specified filename
109
+ // In Node.js/Next.js, the data comes as a File/Blob-like object with arrayBuffer() method
110
+ const filename = pluginInfo.filename ?? "data.bin";
111
+ const blobData = data;
112
+ const buffer = Buffer.from(await blobData.arrayBuffer());
113
+ await fsp.writeFile(path.join(pluginDir, filename), buffer);
114
+ }
115
+ else if (typeof data === "string") {
116
+ // Write JSON data as raw.json
117
+ await fsp.writeFile(path.join(pluginDir, "raw.json"), data);
118
+ }
119
+ }
120
+ // Run post-processing plugins
121
+ await runPostProcessing(recordingDir, metadata.plugins);
122
+ return new Response(null, { status: StatusCodes.CREATED });
123
+ }
124
+ /**
125
+ * Attempt to discover and run post-processing plugins.
126
+ */
127
+ async function runPostProcessing(recordingDir, plugins) {
128
+ // @ts-expect-error this file is provided by the client
129
+ const dynamicImports = (await import("@/.dynamic-imports")).default;
130
+ for (const pluginInfo of plugins) {
131
+ const pluginDir = path.join(recordingDir, packageToDir(pluginInfo.key));
132
+ const dynamicImporter = dynamicImports[`${pluginInfo.key}/liqvid-studio-server-plugin`];
133
+ // no server plugin
134
+ if (!dynamicImporter) {
135
+ continue;
136
+ }
137
+ let serverPlugin;
138
+ try {
139
+ // Try to import the server plugin
140
+ serverPlugin = await dynamicImporter();
141
+ }
142
+ catch (e) {
143
+ console.error(`error loading server plugin for ${pluginInfo.key}`, e);
144
+ continue;
145
+ }
146
+ const plugin = serverPlugin.default ?? serverPlugin;
147
+ if (!plugin.postProcessRecording)
148
+ continue;
149
+ try {
150
+ await plugin.postProcessRecording({ dirname: pluginDir });
151
+ }
152
+ catch (e) {
153
+ console.error(`error in ${pluginInfo.key}`, e);
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,4 @@
1
+ import { getServerState } from "../initialize.mjs";
2
+ export async function getRoot() {
3
+ return Response.json({ serverState: getServerState() });
4
+ }
@@ -0,0 +1,82 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { safeGet } from "have-fun";
5
+ import { StatusCodes } from "http-status-codes";
6
+ /**
7
+ * MIME type mappings for common file extensions
8
+ */
9
+ const MIME_TYPES = {
10
+ ".css": "text/css",
11
+ ".gif": "image/gif",
12
+ ".html": "text/html",
13
+ ".jpeg": "image/jpeg",
14
+ ".jpg": "image/jpeg",
15
+ ".js": "application/javascript",
16
+ ".json": "application/json",
17
+ ".mjs": "application/javascript",
18
+ ".mp3": "audio/mpeg",
19
+ ".mp4": "video/mp4",
20
+ ".ogg": "audio/ogg",
21
+ ".pdf": "application/pdf",
22
+ ".png": "image/png",
23
+ ".svg": "image/svg+xml",
24
+ ".ts": "text/typescript",
25
+ ".tsx": "text/typescript",
26
+ ".txt": "text/plain",
27
+ ".wav": "audio/wav",
28
+ ".webm": "audio/webm",
29
+ ".webp": "image/webp",
30
+ ".woff": "font/woff",
31
+ ".woff2": "font/woff2",
32
+ };
33
+ /**
34
+ * Get MIME type from file extension
35
+ */
36
+ function getMimeType(filePath) {
37
+ const ext = path.extname(filePath).toLowerCase();
38
+ return MIME_TYPES[ext] ?? "application/octet-stream";
39
+ }
40
+ /**
41
+ * Serve static files from the app directory.
42
+ * The `url` param is the path relative to the app directory.
43
+ * Example: /api/liqvid/static?url=/projects/my-video/.liqvid/recordings/test/@liqvid.media/audio.webm
44
+ */
45
+ export async function serveStaticFile(searchParams) {
46
+ const $url = safeGet(searchParams, "url");
47
+ if ($url.isNone) {
48
+ return Response.json({ error: "missing url parameter" }, { status: StatusCodes.BAD_REQUEST });
49
+ }
50
+ const requestedPath = $url.unwrap();
51
+ // Security: Prevent directory traversal attacks
52
+ const normalizedPath = path.normalize(requestedPath);
53
+ if (normalizedPath.includes("..")) {
54
+ return Response.json({ error: "invalid path" }, { status: StatusCodes.BAD_REQUEST });
55
+ }
56
+ // Resolve relative to the app directory
57
+ const appDir = path.join(process.cwd(), "app");
58
+ const absolutePath = path.join(appDir, normalizedPath);
59
+ // Security: Ensure the resolved path is within the app directory
60
+ if (!absolutePath.startsWith(appDir + path.sep)) {
61
+ return Response.json({ error: "invalid path" }, { status: StatusCodes.BAD_REQUEST });
62
+ }
63
+ // Check if file exists
64
+ if (!fs.existsSync(absolutePath)) {
65
+ return Response.json({ error: "file not found" }, { status: StatusCodes.NOT_FOUND });
66
+ }
67
+ // Check if it's a file (not a directory)
68
+ const stat = await fsp.stat(absolutePath);
69
+ if (!stat.isFile()) {
70
+ return Response.json({ error: "not a file" }, { status: StatusCodes.BAD_REQUEST });
71
+ }
72
+ // Read and serve the file
73
+ const content = await fsp.readFile(absolutePath);
74
+ const mimeType = getMimeType(absolutePath);
75
+ return new Response(content, {
76
+ headers: {
77
+ "Content-Length": String(content.length),
78
+ "Content-Type": mimeType,
79
+ },
80
+ status: StatusCodes.OK,
81
+ });
82
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ /** biome-ignore-all lint/complexity/noBannedTypes: intersection types */
2
+ /** biome-ignore-all lint/suspicious/noExplicitAny: heavy type magic here */
3
+ import { Err, Ok } from "have-fun";
4
+ import { listRecordingsOperation, saveRecordingOperation, setProjectMetaOperation, } from "./api/contract.mjs";
5
+ import { fetchJson } from "./utils/dom.mjs";
6
+ const apiRoot = "/api/liqvid";
7
+ function makeFetcher(config) {
8
+ const { endpoint, method = "GET", response: responseModel, search: searchModel, } = config;
9
+ const init = { method };
10
+ if (responseModel) {
11
+ return async (opts) => {
12
+ const queryString = searchModel
13
+ ? "?" + new URLSearchParams(opts.search)
14
+ : "";
15
+ const url = apiRoot + endpoint + queryString;
16
+ if (method === "POST" && opts.body) {
17
+ init.body = JSON.stringify(opts.body);
18
+ }
19
+ const $res = await fetchJson(responseModel, url, init);
20
+ return $res;
21
+ };
22
+ }
23
+ return async (opts) => {
24
+ const queryString = searchModel
25
+ ? "?" + new URLSearchParams(opts.search)
26
+ : "";
27
+ const url = apiRoot + endpoint + queryString;
28
+ if (method === "POST" && opts.body) {
29
+ init.body = JSON.stringify(opts.body);
30
+ }
31
+ try {
32
+ const res = await fetch(url, init);
33
+ if (res.ok) {
34
+ return Ok(undefined);
35
+ }
36
+ return Err(res.json());
37
+ }
38
+ catch (e) {
39
+ return Err(e);
40
+ }
41
+ };
42
+ }
43
+ export const listRecordings = makeFetcher(listRecordingsOperation);
44
+ export const setProjectMeta = makeFetcher(setProjectMetaOperation);
45
+ /**
46
+ * Determine filename for a Blob based on its MIME type.
47
+ */
48
+ function getBlobFilename(blob) {
49
+ if (blob.type.startsWith("video/")) {
50
+ return "video.webm";
51
+ }
52
+ if (blob.type.startsWith("audio/")) {
53
+ return "audio.webm";
54
+ }
55
+ return "data.bin";
56
+ }
57
+ /**
58
+ * Save recording data to the server.
59
+ * Handles both JSON data and Blob data (for media recordings).
60
+ */
61
+ export async function saveRecording(opts) {
62
+ const { search, body } = opts;
63
+ const queryString = "?" + new URLSearchParams(search);
64
+ const url = apiRoot + saveRecordingOperation.endpoint + queryString;
65
+ const formData = new FormData();
66
+ // Add metadata
67
+ const metadata = {
68
+ durationMs: body.durationMs,
69
+ plugins: body.plugins.map(({ key, data }) => ({
70
+ filename: data instanceof Blob ? getBlobFilename(data) : undefined,
71
+ isBlob: data instanceof Blob,
72
+ key,
73
+ })),
74
+ };
75
+ formData.append("metadata", JSON.stringify(metadata));
76
+ // Add plugin data
77
+ for (const { key, data } of body.plugins) {
78
+ if (data instanceof Blob) {
79
+ formData.append(key, data, getBlobFilename(data));
80
+ }
81
+ else {
82
+ formData.append(key, JSON.stringify(data));
83
+ }
84
+ }
85
+ try {
86
+ const res = await fetch(url, {
87
+ body: formData,
88
+ method: "POST",
89
+ });
90
+ if (res.ok) {
91
+ return Ok(undefined);
92
+ }
93
+ return Err(new TypeError(await res.text()));
94
+ }
95
+ catch (e) {
96
+ return Err(e);
97
+ }
98
+ }
@@ -0,0 +1,3 @@
1
+ export const PROJECT_FILE = "project.json";
2
+ export const PROJECT_META_FILE = "project-meta.json";
3
+ export const RECORDING_META_FILE = "recording-meta.json";
@@ -0,0 +1,20 @@
1
+ export { DockableDialog } from "./ui/DockableDialog";
2
+ export * from "./ui/Tabs";
3
+ import { devComponent, devProvider } from "@liqvid/ssr/react";
4
+ export { LiqvidDevToolsProvider as LiqvidDevToolsProviderProd, useProjectContext, } from "./LiqvidDevToolsProvider";
5
+ export { RecordingControl as RecordingControlProd, } from "./recording/RecordingControl";
6
+ /* ------------------------- dev-only exports ------------------------- */
7
+ /**
8
+ * Liqvid dev tools provider.
9
+ *
10
+ * Only operates in development. If you want this in production,
11
+ * use {@link LiqvidDevToolsProviderProd} instead.
12
+ */
13
+ export const LiqvidDevToolsProvider = devProvider(() => import("./LiqvidDevToolsProvider").then((imports) => imports.LiqvidDevToolsProvider));
14
+ /**
15
+ * Liqvid recording control.
16
+ *
17
+ * Only renders in development. If you want this in production,
18
+ * use {@link RecordingControlProd} instead.
19
+ */
20
+ export const RecordingControl = devComponent(() => import("./recording/RecordingControl").then((imports) => imports.RecordingControl));
@@ -0,0 +1,50 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { runNextBuild } from "@liqvid/cli/build";
4
+ import { execa } from "execa";
5
+ import { watchAssets } from "./jobs/watch-assets.mjs";
6
+ import { watchProjectFiles } from "./jobs/watch-project-files.mjs";
7
+ export { runNextBuild };
8
+ const symbol = Symbol.for("@liqvid/server");
9
+ const DEFAULT_PRODUCTION_SERVER_PORT = 4000;
10
+ export async function initializeServer() {
11
+ const state = getServerState();
12
+ const { jobs, projects } = state;
13
+ jobs.watchAssets ??= watchAssets();
14
+ jobs.watchProjectFiles ??= jobs.watchAssets.then(() => watchProjectFiles(projects));
15
+ jobs.productionServer ??= startProductionServer(state);
16
+ await jobs.watchProjectFiles;
17
+ }
18
+ async function startProductionServer(state) {
19
+ const outDir = path.join(process.cwd(), "out");
20
+ // Check if 'out' directory exists, if not run 'next build'
21
+ if (!fs.existsSync(outDir)) {
22
+ console.log("'out' directory not found, running 'next build'...");
23
+ await runNextBuild();
24
+ }
25
+ // Start the production server
26
+ const port = Number(process.env.LIQVID_PRODUCTION_SERVER_PORT) ||
27
+ DEFAULT_PRODUCTION_SERVER_PORT;
28
+ state.productionServerPort = port;
29
+ console.log(`Starting production server on port ${port}...`);
30
+ execa("npx", ["serve", "out", "-p", String(port)], {
31
+ cwd: process.cwd(),
32
+ env: { ...process.env, NODE_ENV: "production" },
33
+ stderr: "inherit",
34
+ stdout: "inherit",
35
+ });
36
+ }
37
+ export function getServerState() {
38
+ if (!(symbol in globalThis)) {
39
+ globalThis[symbol] = {
40
+ jobs: {
41
+ productionServer: null,
42
+ watchAssets: null,
43
+ watchProjectFiles: null,
44
+ },
45
+ productionServerPort: DEFAULT_PRODUCTION_SERVER_PORT,
46
+ projects: {},
47
+ };
48
+ }
49
+ return globalThis[symbol];
50
+ }
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { execa } from "execa";
6
+ import Handlebars from "handlebars";
7
+ import { PROJECT_META_FILE } from "../conventions.mjs";
8
+ import { getBiomePath } from "../utils/fs.mjs";
9
+ import { debounce } from "../utils/misc.mjs";
10
+ export const ASSETS_DIRNAME = ".liqvid";
11
+ /** whether a file should be omitted from the directory listing */
12
+ function isForbidden(_filename, basename) {
13
+ if (basename === ".DS_Store")
14
+ return true;
15
+ if (basename === "types.ts")
16
+ return true;
17
+ return false;
18
+ }
19
+ function shouldIgnore({ basename, filename, }) {
20
+ if (filename.endsWith("~"))
21
+ return true;
22
+ if (basename === ".DS_Store")
23
+ return true;
24
+ return false;
25
+ }
26
+ const TARGET_DIR = path.join(process.cwd(), "app");
27
+ const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
28
+ export async function watchAssets() {
29
+ Handlebars.registerHelper("json", (obj) => {
30
+ return new Handlebars.SafeString(JSON.stringify(obj, null, 2));
31
+ });
32
+ fs.watch(TARGET_DIR, { recursive: true }, async (_eventName, relPath) => {
33
+ if (!relPath)
34
+ return;
35
+ const filename = path.join(TARGET_DIR, relPath);
36
+ const dirname = path.dirname(filename);
37
+ const basename = path.basename(filename);
38
+ if (shouldIgnore({ basename, filename }))
39
+ return;
40
+ // assets
41
+ const $_ = filename.match(/^.*\/\.liqvid(?=\/)/);
42
+ if (!$_)
43
+ return null;
44
+ if (basename === "types.ts" || basename === PROJECT_META_FILE)
45
+ return;
46
+ const assetsDir = $_[0];
47
+ const biomePath = await getBiomePath(dirname);
48
+ debounce(() => generateProjectTypes({ assetsDir, biomePath }), assetsDir);
49
+ });
50
+ }
51
+ /**
52
+ * Generate the types.ts file inside the assets dir.
53
+ */
54
+ async function generateProjectTypes({ assetsDir, biomePath, }) {
55
+ const directoryStructure = await listDir(assetsDir);
56
+ runTemplate({
57
+ biomePath,
58
+ data: {
59
+ directoryStructure,
60
+ },
61
+ out: path.join(assetsDir, "types.ts"),
62
+ template: "types.ts.hbs",
63
+ });
64
+ }
65
+ /**
66
+ * Generate a file from a Handlebars template, and format the result with Biome (if available).
67
+ */
68
+ export async function runTemplate({ biomePath, data, out, template, }) {
69
+ console.debug(`compiling ${template} -> ${out}`);
70
+ const templateHbs = await fsp.readFile(path.join(TEMPLATES_DIR, template), "utf8");
71
+ try {
72
+ const template = Handlebars.compile(templateHbs);
73
+ const result = template(data);
74
+ await fsp.writeFile(out, result);
75
+ // invoke biome
76
+ if (biomePath.isSome) {
77
+ await execa(biomePath.unwrap(), ["check", "--fix", out]);
78
+ }
79
+ }
80
+ catch (e) {
81
+ console.error(e);
82
+ }
83
+ }
84
+ export async function listDir(dirname) {
85
+ const dir = await fsp.readdir(dirname);
86
+ return Object.fromEntries(await Promise.all(dir.reduce((acc, basename) => {
87
+ const filename = path.join(dirname, basename);
88
+ if (isForbidden(filename, basename))
89
+ return acc;
90
+ const stats = fs.statSync(filename);
91
+ if (stats.isDirectory()) {
92
+ acc.push(listDir(filename).then((result) => [basename, result]));
93
+ }
94
+ else {
95
+ acc.push(Promise.resolve([basename, null]));
96
+ }
97
+ return acc;
98
+ }, [])));
99
+ }