@ventually/ui 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # `@ventually/ui`
2
+
3
+ React UI components and a standalone monitor app for Eventually queues.
4
+
5
+ ## Library
6
+
7
+ Use the dashboard inside your own React app:
8
+
9
+ ```tsx
10
+ import { EventuallyDashboard, EventuallyUIProvider } from "@ventually/ui";
11
+ import "@ventually/ui/styles.css"
12
+
13
+ export function App() {
14
+ return (
15
+ <EventuallyUIProvider endpoint="/api/playground">
16
+ <EventuallyDashboard />
17
+ </EventuallyUIProvider>
18
+ );
19
+ }
20
+ ```
21
+
22
+ ## Standalone Monitor
23
+
24
+ Build the package:
25
+
26
+ ```bash
27
+ bun --filter @ventually/ui build
28
+ ```
29
+
30
+ Start the monitor app:
31
+
32
+ ```bash
33
+ node packages/eventually-ui/dist/cli.js start
34
+ ```
35
+
36
+ By default the app uses `/api/playground` as its backend endpoint. Override it with `--endpoint` to point at your own queue backend:
37
+
38
+ ```bash
39
+ node packages/eventually-ui/dist/cli.js start \
40
+ --host 127.0.0.1 \
41
+ --port 3210 \
42
+ --endpoint https://your-backend.example.com/api/queues
43
+ ```
44
+
45
+ Available flags:
46
+
47
+ - `--host`: host to bind the local monitor server. Default: `127.0.0.1`
48
+ - `--port`: port to bind the local monitor server. Default: `3210`
49
+ - `--endpoint`: Eventually UI API endpoint used by the React app. Default: `/api/playground`
50
+
51
+ `start` only serves the built monitor app from `dist/standalone`. It does not create a queue backend server.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ import { createReadStream } from "node:fs";
3
+ import { access, readFile, stat } from "node:fs/promises";
4
+ import { createServer as createHttpServer } from "node:http";
5
+ import { dirname, extname, relative, resolve, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ const DEFAULT_HOST = "127.0.0.1";
8
+ const DEFAULT_PORT = 3210;
9
+ const DEFAULT_ENDPOINT = "/api/playground";
10
+ const CONTENT_TYPES = {
11
+ ".css": "text/css; charset=utf-8",
12
+ ".html": "text/html; charset=utf-8",
13
+ ".ico": "image/x-icon",
14
+ ".jpeg": "image/jpeg",
15
+ ".jpg": "image/jpeg",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".json": "application/json; charset=utf-8",
18
+ ".map": "application/json; charset=utf-8",
19
+ ".png": "image/png",
20
+ ".svg": "image/svg+xml",
21
+ };
22
+ async function main() {
23
+ const command = process.argv[2] ?? "start";
24
+ if (command !== "start") {
25
+ console.error(`Unsupported command: ${command}`);
26
+ process.exit(1);
27
+ }
28
+ const port = readFlagValue("--port")
29
+ ? Number(readFlagValue("--port"))
30
+ : DEFAULT_PORT;
31
+ const host = readFlagValue("--host") ?? DEFAULT_HOST;
32
+ const endpoint = readFlagValue("--endpoint") ?? DEFAULT_ENDPOINT;
33
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
34
+ const standaloneRoot = resolve(packageRoot, "dist/standalone");
35
+ const standaloneIndexPath = resolve(standaloneRoot, "index.html");
36
+ await assertStandaloneBuildArtifacts(standaloneRoot, standaloneIndexPath);
37
+ const server = createHttpServer(async (request, response) => {
38
+ if (!request.url) {
39
+ response.statusCode = 400;
40
+ response.end("Missing URL");
41
+ return;
42
+ }
43
+ const requestPathname = readPathname(request.url);
44
+ if (!requestPathname) {
45
+ response.statusCode = 400;
46
+ response.end("Invalid URL");
47
+ return;
48
+ }
49
+ if (requestPathname.startsWith("/api/")) {
50
+ response.statusCode = 404;
51
+ response.end("Not found");
52
+ return;
53
+ }
54
+ await serveStandaloneAsset(request.url, response, {
55
+ endpoint,
56
+ standaloneRoot,
57
+ standaloneIndexPath,
58
+ });
59
+ });
60
+ server.listen(port, host, () => {
61
+ console.log(`@ventually/ui listening on http://${host}:${port} (endpoint: ${endpoint})`);
62
+ });
63
+ }
64
+ async function assertStandaloneBuildArtifacts(standaloneRoot, standaloneIndexPath) {
65
+ try {
66
+ const [rootStats, indexStats] = await Promise.all([
67
+ stat(standaloneRoot),
68
+ stat(standaloneIndexPath),
69
+ ]);
70
+ if (!rootStats.isDirectory() || !indexStats.isFile()) {
71
+ throw new Error("Standalone build artifacts missing");
72
+ }
73
+ }
74
+ catch {
75
+ console.error(`@ventually/ui standalone build artifacts not found at ${standaloneRoot}.\nRun \`bun --filter @ventually/ui build:standalone\` and try again.`);
76
+ process.exit(1);
77
+ }
78
+ }
79
+ async function serveStandaloneAsset(rawUrl, response, paths) {
80
+ const pathname = readPathname(rawUrl);
81
+ if (!pathname) {
82
+ response.statusCode = 400;
83
+ response.end("Invalid URL");
84
+ return;
85
+ }
86
+ const requestedPath = pathname === "/" ? paths.standaloneIndexPath : resolve(paths.standaloneRoot, `.${pathname}`);
87
+ if (!isPathInsideRoot(paths.standaloneRoot, requestedPath)) {
88
+ response.statusCode = 404;
89
+ response.end("Not found");
90
+ return;
91
+ }
92
+ const requestedStats = await safeStat(requestedPath);
93
+ if (requestedStats?.isFile()) {
94
+ if (requestedPath === paths.standaloneIndexPath) {
95
+ await serveInjectedIndexHtml(paths.standaloneIndexPath, paths.endpoint, response);
96
+ return;
97
+ }
98
+ await streamFile(requestedPath, response);
99
+ return;
100
+ }
101
+ if (extname(pathname)) {
102
+ response.statusCode = 404;
103
+ response.end("Not found");
104
+ return;
105
+ }
106
+ await serveInjectedIndexHtml(paths.standaloneIndexPath, paths.endpoint, response);
107
+ }
108
+ function readFlagValue(flag) {
109
+ const flagIndex = process.argv.indexOf(flag);
110
+ const value = flagIndex >= 0 ? process.argv[flagIndex + 1] : undefined;
111
+ return value ? value : null;
112
+ }
113
+ function isPathInsideRoot(root, path) {
114
+ const relativePath = relative(root, path);
115
+ return (relativePath === "" ||
116
+ (!relativePath.startsWith(`..${sep}`) &&
117
+ relativePath !== ".." &&
118
+ !relativePath.includes(`${sep}..${sep}`)));
119
+ }
120
+ async function safeStat(path) {
121
+ try {
122
+ return await stat(path);
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ async function streamFile(path, response) {
129
+ await access(path);
130
+ response.statusCode = 200;
131
+ response.setHeader("content-type", contentTypeForPath(path));
132
+ await new Promise((resolvePromise, rejectPromise) => {
133
+ const stream = createReadStream(path);
134
+ stream.on("error", rejectPromise);
135
+ response.on("finish", resolvePromise);
136
+ response.on("close", resolvePromise);
137
+ stream.pipe(response);
138
+ });
139
+ }
140
+ async function serveInjectedIndexHtml(path, endpoint, response) {
141
+ const html = await readFile(path, "utf8");
142
+ response.statusCode = 200;
143
+ response.setHeader("content-type", "text/html; charset=utf-8");
144
+ response.end(injectEndpointIntoHtml(html, endpoint));
145
+ }
146
+ function contentTypeForPath(path) {
147
+ return CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
148
+ }
149
+ function injectEndpointIntoHtml(html, endpoint) {
150
+ const configScript = `<script>window.__EVENTUALLY_UI_ENDPOINT__=${JSON.stringify(endpoint)};</script>`;
151
+ return html.includes("</head>")
152
+ ? html.replace("</head>", `${configScript}</head>`)
153
+ : `${configScript}${html}`;
154
+ }
155
+ function readPathname(rawUrl) {
156
+ try {
157
+ return decodeURIComponent(new URL(rawUrl, "http://localhost").pathname);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ void main();
@@ -0,0 +1,20 @@
1
+ import { type ReactNode } from "react";
2
+ import type { EventuallyUIAction, EventuallyUISnapshot } from "./types.js";
3
+ interface EventuallyUIContextValue {
4
+ endpoint: string;
5
+ error: string | null;
6
+ pending: string | null;
7
+ snapshot: EventuallyUISnapshot | null;
8
+ stale: boolean;
9
+ refresh(): Promise<void>;
10
+ perform(action: EventuallyUIAction, label?: string): Promise<void>;
11
+ }
12
+ export declare function EventuallyUIProvider({ children, endpoint, refreshInterval, initialSnapshot, }: {
13
+ children: ReactNode;
14
+ endpoint?: string;
15
+ refreshInterval?: number;
16
+ initialSnapshot?: EventuallyUISnapshot | null;
17
+ }): import("react/jsx-runtime").JSX.Element;
18
+ export declare function useEventuallyUI(): EventuallyUIContextValue;
19
+ export {};
20
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAE3E,UAAU,wBAAwB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACtC,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,OAAO,CAAC,MAAM,EAAE,kBAAkB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpE;AAMD,wBAAgB,oBAAoB,CAAC,EACnC,QAAQ,EACR,QAAiD,EACjD,eAAsB,EACtB,eAAsB,GACvB,EAAE;IACD,QAAQ,EAAE,SAAS,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAAC;CAC/C,2CAmFA;AAED,wBAAgB,eAAe,6BAQ9B"}
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, startTransition, useContext, useEffect, useMemo, useState, } from "react";
3
+ const EventuallyUIContext = createContext(null);
4
+ export function EventuallyUIProvider({ children, endpoint = "http://localhost:3210/api/playground", refreshInterval = 1200, initialSnapshot = null, }) {
5
+ const [snapshot, setSnapshot] = useState(initialSnapshot);
6
+ const [error, setError] = useState(null);
7
+ const [pending, setPending] = useState(null);
8
+ async function refresh() {
9
+ const response = await fetch(endpoint, { cache: "no-store" });
10
+ if (!response.ok) {
11
+ throw new Error(`Eventually UI refresh failed with ${response.status}`);
12
+ }
13
+ const next = (await response.json());
14
+ setError(null);
15
+ startTransition(() => {
16
+ setSnapshot(next);
17
+ });
18
+ }
19
+ async function perform(action, label = action.action) {
20
+ setPending(label);
21
+ try {
22
+ const response = await fetch(endpoint, {
23
+ method: "POST",
24
+ headers: {
25
+ "content-type": "application/json",
26
+ },
27
+ body: JSON.stringify(action),
28
+ });
29
+ if (!response.ok) {
30
+ throw new Error(`Eventually UI action failed with ${response.status}`);
31
+ }
32
+ const next = (await response.json());
33
+ setError(null);
34
+ startTransition(() => {
35
+ setSnapshot(next);
36
+ });
37
+ }
38
+ catch (error) {
39
+ setError(error instanceof Error ? error.message : "Eventually UI request failed");
40
+ throw error;
41
+ }
42
+ finally {
43
+ setPending(null);
44
+ }
45
+ }
46
+ useEffect(() => {
47
+ void refresh().catch((error) => {
48
+ setError(error instanceof Error ? error.message : "Eventually UI refresh failed");
49
+ });
50
+ const timer = window.setInterval(() => {
51
+ void refresh().catch((error) => {
52
+ setError(error instanceof Error
53
+ ? error.message
54
+ : "Eventually UI refresh failed");
55
+ });
56
+ }, refreshInterval);
57
+ return () => window.clearInterval(timer);
58
+ }, [endpoint, refreshInterval]);
59
+ const value = useMemo(() => ({
60
+ endpoint,
61
+ error,
62
+ pending,
63
+ snapshot,
64
+ stale: error !== null,
65
+ refresh,
66
+ perform,
67
+ }), [endpoint, error, pending, snapshot]);
68
+ return (_jsx(EventuallyUIContext.Provider, { value: value, children: children }));
69
+ }
70
+ export function useEventuallyUI() {
71
+ const context = useContext(EventuallyUIContext);
72
+ if (!context) {
73
+ throw new Error("useEventuallyUI must be used inside EventuallyUIProvider.");
74
+ }
75
+ return context;
76
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=context.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.test.d.ts","sourceRoot":"","sources":["../src/context.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { EventuallyUIProvider, useEventuallyUI } from "./context.js";
6
+ function Probe() {
7
+ const { error, stale } = useEventuallyUI();
8
+ return (_jsxs("div", { children: [_jsx("span", { "data-testid": "stale", children: String(stale) }), _jsx("span", { "data-testid": "error", children: error ?? "none" })] }));
9
+ }
10
+ describe("EventuallyUIProvider", () => {
11
+ afterEach(() => {
12
+ cleanup();
13
+ vi.unstubAllGlobals();
14
+ });
15
+ it("surfaces refresh failures to consumers", async () => {
16
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
17
+ ok: false,
18
+ status: 503,
19
+ }));
20
+ render(_jsx(EventuallyUIProvider, { endpoint: "/api/playground", refreshInterval: 10_000, children: _jsx(Probe, {}) }));
21
+ await waitFor(() => {
22
+ expect(screen.getByTestId("stale").textContent).toBe("true");
23
+ expect(screen.getByTestId("error").textContent).toBe("Eventually UI refresh failed with 503");
24
+ });
25
+ });
26
+ });
@@ -0,0 +1,3 @@
1
+ import "./index.css";
2
+ export declare function EventuallyDashboard(): import("react/jsx-runtime").JSX.Element;
3
+ //# sourceMappingURL=dashboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard.d.ts","sourceRoot":"","sources":["../src/dashboard.tsx"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAkUrB,wBAAgB,mBAAmB,4CAggBlC"}