@everysk/apps-devkit 0.2.0 → 0.3.0-beta-20261019175049

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.
@@ -0,0 +1,216 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import type { Plugin } from "vite";
4
+
5
+ import { createApiTokenSource, type ApiTokenSource } from "../apiToken.js";
6
+
7
+ /** Every path under the platform's file route, claimed whole so none falls through to `index.html`. */
8
+ const FILE_ROUTE = /^\/file\//;
9
+
10
+ /**
11
+ * A file's url as the platform serves it: the hex of a version-4 UUID, as everysk/client's
12
+ * `FileRequestHandler` matches it (`RE_FILE_URL`).
13
+ */
14
+ const FILE_URL = /^\/file\/([0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15})\/?(?:\?.*)?$/;
15
+
16
+ /** What `createDevFileRoute` needs: the API and its token, a `fetch` and a log to replace in tests. */
17
+ type DevFileRouteParams = ApiTokenSource & {
18
+ fetch?: typeof fetch;
19
+ log?: (message: string) => void;
20
+ };
21
+
22
+ type ListedFile = { name: string; content_type: string | null; data: string | null };
23
+
24
+ type RouteRequest = Pick<IncomingMessage, "url" | "method">;
25
+ type RouteResponse = Pick<ServerResponse, "statusCode" | "setHeader" | "end">;
26
+
27
+ /** Why a file could not be served, with the status to answer it with. */
28
+ class FileRouteError extends Error {
29
+ readonly status: number;
30
+
31
+ constructor(status: number, message: string) {
32
+ super(message);
33
+ this.status = status;
34
+ }
35
+ }
36
+
37
+ /** The error and, when Node gives one, its cause, which is where fetch keeps ECONNREFUSED or DNS. */
38
+ const describeError = (error: unknown): string => {
39
+ if (!(error instanceof Error)) return String(error);
40
+ return error.cause instanceof Error
41
+ ? `${error.message}: ${error.cause.message}`
42
+ : error.message;
43
+ };
44
+
45
+ /** The API's own `message` from a refusal, or the start of whatever came back instead. */
46
+ const refusalOf = async (response: Response): Promise<string> => {
47
+ const text = await response.text();
48
+ try {
49
+ return (JSON.parse(text) as { message?: string }).message ?? text.slice(0, 200);
50
+ } catch {
51
+ return text.slice(0, 200);
52
+ }
53
+ };
54
+
55
+ /**
56
+ * The file whose stored url is `/<hash>`, read with its content. A file stores its url as
57
+ * `/<hash>` and the API adds `/file` only when it returns it, and the file list accepts a `url`
58
+ * filter without a workspace (everysk-lib 1.13.4 `sdk/entities/file`, everysk/client
59
+ * `File.validate_query`).
60
+ */
61
+ const findFile = async (
62
+ fetchApi: typeof fetch,
63
+ apiUrl: string,
64
+ accessToken: string,
65
+ hash: string,
66
+ ) => {
67
+ const query = JSON.stringify({ filters: [["url", `/${hash}`]], page_size: 1 });
68
+ let response: Response;
69
+ try {
70
+ response = await fetchApi(`${apiUrl}/files?query=${encodeURIComponent(query)}`, {
71
+ headers: { Authorization: `Bearer ${accessToken}` },
72
+ });
73
+ } catch (error) {
74
+ throw new FileRouteError(502, `The API could not be reached: ${describeError(error)}`);
75
+ }
76
+
77
+ if (!response.ok) {
78
+ const refusal = await refusalOf(response);
79
+ throw new FileRouteError(
80
+ 502,
81
+ `The API refused the file: ${response.status} ${response.statusText}: ${refusal}`,
82
+ );
83
+ }
84
+
85
+ let body: { files?: ListedFile[] };
86
+ try {
87
+ body = (await response.json()) as { files?: ListedFile[] };
88
+ } catch {
89
+ throw new FileRouteError(
90
+ 502,
91
+ `The API answered with ${response.headers.get("content-type")}, not JSON.`,
92
+ );
93
+ }
94
+ if (!Array.isArray(body.files)) {
95
+ throw new FileRouteError(
96
+ 502,
97
+ `The file list came back without files: ${JSON.stringify(body).slice(0, 200)}`,
98
+ );
99
+ }
100
+
101
+ return body.files[0];
102
+ };
103
+
104
+ /** The content of a found file, refusing what would be served as an empty or corrupt file. */
105
+ const contentOf = (file: ListedFile, hash: string): Buffer => {
106
+ if (typeof file.data !== "string") {
107
+ throw new FileRouteError(
108
+ 502,
109
+ `The API returned ${file.name} (/file/${hash}) without its content.`,
110
+ );
111
+ }
112
+ if (file.data.startsWith("data:")) {
113
+ throw new FileRouteError(
114
+ 422,
115
+ `${file.name} was stored as a data URL, so its bytes are not the file: upload it again, read with fileToBase64.`,
116
+ );
117
+ }
118
+ return Buffer.from(file.data, "base64");
119
+ };
120
+
121
+ /**
122
+ * createDevFileRoute
123
+ *
124
+ * The dev server's stand-in for the platform's `/file/<hash>` route. On the platform a file's
125
+ * `url` is that route, served with the user's session and shown inline; the dev server has no
126
+ * session, so the file is found through the API with the dev proxy's OAuth2 token instead, by
127
+ * its stored `url`, and its base64 content is decoded here and sent with its own type. So an app
128
+ * shows a file through its `url` wherever it runs.
129
+ *
130
+ * Read with the OAuth2 client's permissions, not a user's, as everything through the dev proxy is.
131
+ * Every path under `/file/` is answered here, never handed on: Vite would answer it with the
132
+ * app's `index.html` and a 200. A file that cannot be served gets a status that says why, and a
133
+ * line in the terminal, since an `<img>` or `<iframe>` never shows the body. Nothing escapes the
134
+ * middleware: Connect ignores its promise, and a rejection would end the dev server.
135
+ *
136
+ * @param params - The API, its token and last failure, and a `fetch` and a log to replace in tests.
137
+ * @returns A Connect middleware that answers `/file/` and passes everything else on.
138
+ */
139
+ export const createDevFileRoute =
140
+ ({
141
+ apiUrl,
142
+ token,
143
+ lastError,
144
+ fetch: fetchApi = fetch,
145
+ log = console.warn,
146
+ }: DevFileRouteParams) =>
147
+ async (req: RouteRequest, res: RouteResponse, next: () => void) => {
148
+ const url = req.url ?? "";
149
+ if (!FILE_ROUTE.test(url)) return next();
150
+
151
+ const fail = (status: number, message: string) => {
152
+ log(`[dev server] ${url}: ${message}`);
153
+ res.statusCode = status;
154
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
155
+ res.end(message);
156
+ };
157
+
158
+ if (req.method !== "GET" && req.method !== "HEAD")
159
+ return fail(405, "A file is only read here.");
160
+
161
+ const hash = FILE_URL.exec(url)?.[1];
162
+ if (!hash)
163
+ return fail(400, "Not a file url: /file/ and the 32-character hex hash of a file.");
164
+
165
+ const accessToken = token();
166
+ if (!accessToken) {
167
+ return fail(
168
+ 503,
169
+ `No OAuth2 token for EVERYSK_API_URL: ${lastError()?.message ?? "not there yet"}`,
170
+ );
171
+ }
172
+
173
+ try {
174
+ const file = await findFile(fetchApi, apiUrl, accessToken, hash);
175
+ if (!file) {
176
+ return fail(
177
+ 404,
178
+ `No file has the url /file/${hash}, as the OAuth2 client EVERYSK_API_CLIENT_ID sees.`,
179
+ );
180
+ }
181
+
182
+ const content = contentOf(file, hash);
183
+ const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
184
+ res.setHeader("Content-Type", file.content_type || "application/octet-stream");
185
+ res.setHeader("Content-Length", content.length);
186
+ res.setHeader("Content-Disposition", disposition);
187
+ res.statusCode = 200;
188
+ res.end(req.method === "HEAD" ? undefined : content);
189
+ } catch (error) {
190
+ fail(error instanceof FileRouteError ? error.status : 502, describeError(error));
191
+ }
192
+ };
193
+
194
+ /**
195
+ * serveDevFilesPlugin
196
+ *
197
+ * Mounts `createDevFileRoute` on the dev server and on `vite preview`, neither of which has the
198
+ * platform's `/file/` route, logging through Vite's own logger.
199
+ *
200
+ * @param {ApiTokenSource} [tokens] - The token to send; created when the server starts if
201
+ * omitted. `defineAppConfig` passes the one it shares with the `/api` proxy.
202
+ */
203
+ export function serveDevFilesPlugin(tokens?: ApiTokenSource): Plugin {
204
+ const route = (log: (message: string) => void) =>
205
+ createDevFileRoute({ ...(tokens ?? createApiTokenSource()), log });
206
+
207
+ return {
208
+ name: "serve-dev-files",
209
+ configureServer(server) {
210
+ server.middlewares.use(route((message) => server.config.logger.warn(message)));
211
+ },
212
+ configurePreviewServer(server) {
213
+ server.middlewares.use(route((message) => server.config.logger.warn(message)));
214
+ },
215
+ };
216
+ }
@@ -1,88 +1,40 @@
1
1
  import type { ProxyOptions } from "vite";
2
2
 
3
- // Refresh the OAuth2 token this many ms before it actually expires, so an in-flight
4
- // request never races a just-expired token.
5
- const REFRESH_SKEW_MS = 60_000;
3
+ import { createApiTokenSource, type ApiTokenSource } from "./apiToken.js";
6
4
 
7
5
  /**
8
6
  * createDevServerProxy — Vite dev proxy for `/api`, authenticated with OAuth2 client credentials.
9
7
  *
10
8
  * Forwards `/api/*` to `EVERYSK_API_URL` (stripping the `/api` prefix) and injects an
11
- * `Authorization: Bearer <access_token>` header. The token is obtained from
12
- * `${EVERYSK_API_URL}/oauth2/token` with the `client_credentials` grant, cached in memory, and
13
- * refreshed in the background shortly before it expires. It is fetched eagerly at startup so a
14
- * token is ready before the first API call.
9
+ * `Authorization: Bearer <access_token>` header, from `createApiTokenSource`.
15
10
  *
11
+ * @param {ApiTokenSource} [tokens] - The token to send; a new source when omitted. The preset
12
+ * passes the one it shares with the `/file/` route.
16
13
  * @returns {Record<string, string | ProxyOptions>} The Vite `server.proxy` config for `/api`.
17
- * @throws {Error} If `EVERYSK_API_URL`, `EVERYSK_API_CLIENT_ID`, or `EVERYSK_API_CLIENT_SECRET`
18
- * is missing.
14
+ * @throws {Error} If `tokens` is omitted and `EVERYSK_API_URL`, `EVERYSK_API_CLIENT_ID`, or
15
+ * `EVERYSK_API_CLIENT_SECRET` is missing.
19
16
  */
20
- export function createDevServerProxy(): Record<string, string | ProxyOptions> {
21
- const EVERYSK_API_URL = process.env.EVERYSK_API_URL;
22
- if (!EVERYSK_API_URL) throw new Error("EVERYSK_API_URL must be set.");
23
-
24
- const clientId = process.env.EVERYSK_API_CLIENT_ID;
25
- const clientSecret = process.env.EVERYSK_API_CLIENT_SECRET;
26
- if (!clientId || !clientSecret) {
27
- throw new Error("EVERYSK_API_CLIENT_ID and EVERYSK_API_CLIENT_SECRET must be set (OAuth2 client credentials).");
28
- }
29
- // Captured into an object so the narrowed `string` type survives inside the async closure below.
30
- const credentials = { clientId, clientSecret };
31
-
32
- const tokenUrl = `${EVERYSK_API_URL.replace(/\/$/, "")}/oauth2/token`;
33
- let accessToken: string | null = null;
34
- let refreshTimer: ReturnType<typeof setTimeout> | null = null;
35
-
36
- /**
37
- * refreshToken — fetch a fresh access token and schedule the next refresh before it expires.
38
- *
39
- * @returns {Promise<void>} Resolves once `accessToken` is updated.
40
- * @throws {Error} If the token request fails or returns no `access_token`.
41
- */
42
- async function refreshToken(): Promise<void> {
43
- const res = await fetch(tokenUrl, {
44
- method: "POST",
45
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
46
- body: new URLSearchParams({
47
- grant_type: "client_credentials",
48
- client_id: credentials.clientId,
49
- client_secret: credentials.clientSecret,
50
- }),
51
- });
52
- if (!res.ok) throw new Error(`OAuth2 token request failed: ${res.status} ${res.statusText}`);
53
-
54
- const data = (await res.json()) as { access_token?: string; expires_in?: number };
55
- if (!data.access_token) throw new Error("OAuth2 token response had no access_token.");
56
- accessToken = data.access_token;
57
-
58
- const ttlMs = (data.expires_in ?? 3600) * 1000;
59
- if (refreshTimer) clearTimeout(refreshTimer);
60
- refreshTimer = setTimeout(() => {
61
- void refreshToken().catch((error) => console.error("[dev proxy] OAuth2 token refresh failed:", error));
62
- }, Math.max(ttlMs - REFRESH_SKEW_MS, 30_000));
63
- refreshTimer.unref?.();
64
- }
65
-
66
- // Warm the token before the first request; a failure is logged, not fatal (the request will 401).
67
- void refreshToken().catch((error) => console.error("[dev proxy] initial OAuth2 token fetch failed:", error));
68
-
69
- return {
70
- "/api": {
71
- target: EVERYSK_API_URL,
72
- changeOrigin: true,
73
- xfwd: true,
74
- rewrite: (p) => p.replace(/^\/api(?=\/|$)/, ""),
75
-
76
- configure: (proxy) => {
77
- proxy.on("proxyReq", (proxyReq) => {
78
- proxyReq.removeHeader("origin");
79
- proxyReq.removeHeader("referer");
80
-
81
- if (accessToken) {
82
- proxyReq.setHeader("Authorization", `Bearer ${accessToken}`);
83
- }
84
- });
85
- },
86
- },
87
- };
17
+ export function createDevServerProxy(
18
+ tokens: ApiTokenSource = createApiTokenSource(),
19
+ ): Record<string, string | ProxyOptions> {
20
+ return {
21
+ "/api": {
22
+ target: tokens.apiUrl,
23
+ changeOrigin: true,
24
+ xfwd: true,
25
+ rewrite: (p) => p.replace(/^\/api(?=\/|$)/, ""),
26
+
27
+ configure: (proxy) => {
28
+ proxy.on("proxyReq", (proxyReq) => {
29
+ proxyReq.removeHeader("origin");
30
+ proxyReq.removeHeader("referer");
31
+
32
+ const accessToken = tokens.token();
33
+ if (accessToken) {
34
+ proxyReq.setHeader("Authorization", `Bearer ${accessToken}`);
35
+ }
36
+ });
37
+ },
38
+ },
39
+ };
88
40
  }
@@ -5,6 +5,6 @@
5
5
  ],
6
6
  "extends": "./app.json",
7
7
  "compilerOptions": {
8
- "types": ["vitest/globals", "@testing-library/jest-dom", "node", "vite/client"]
8
+ "types": ["vitest/globals", "@testing-library/jest-dom/vitest", "node", "vite/client"]
9
9
  }
10
10
  }