@dytsou/shorten-url 2.4.4 → 2.4.5
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 +88 -50
- package/config/config.example.js +137 -0
- package/config/config.js +19 -0
- package/docs/about.md +360 -0
- package/docs/api/openapi.yaml +441 -81
- package/docs/flagship.md +58 -0
- package/docs/plans/2026-09-06-001-refactor-worker-hosted-react-assets-plan.md +308 -0
- package/frontend/about-template.html +124 -0
- package/frontend/index.html +17 -0
- package/frontend/package.json +20 -0
- package/frontend/pnpm-lock.yaml +1129 -0
- package/frontend/src/App.jsx +1264 -0
- package/frontend/src/api.js +272 -0
- package/frontend/src/local-flag-mock.js +55 -0
- package/frontend/src/main.jsx +4 -0
- package/frontend/src/styles/app.css +2253 -0
- package/frontend/static/favicon.svg +5 -0
- package/frontend/vite.config.js +34 -0
- package/package.json +6 -4
- package/src/lib/access.js +241 -1
- package/src/lib/assets.js +58 -0
- package/src/lib/endpoints.js +33 -8
- package/src/lib/feature-toggle.js +464 -0
- package/src/lib/flag-management.js +137 -0
- package/src/lib/flag-routes.js +155 -0
- package/src/lib/flagship.js +269 -0
- package/src/lib/observability.js +34 -0
- package/src/lib/responses.js +39 -3
- package/src/lib/runtime-config.js +90 -0
- package/src/lib/safety.js +15 -0
- package/src/lib/shortener.js +2 -2
- package/src/lib/worker-handler.js +122 -0
- package/src/worker.js +3 -66
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
export const DEFAULT_SETTINGS_PATH = "/settings";
|
|
2
|
+
export const DEFAULT_SHORTEN_PATH = "/";
|
|
3
|
+
export const WORKSPACE_TABS = [
|
|
4
|
+
{ id: "shorten", label: "Shorten" },
|
|
5
|
+
{ id: "settings", label: "Flagship" },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
const FALLBACK_ORIGIN = "http://localhost";
|
|
9
|
+
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
10
|
+
|
|
11
|
+
function firstNonEmpty(...values) {
|
|
12
|
+
return values.find((value) => typeof value === "string" && value.trim())?.trim() || "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readMeta(documentRef, name) {
|
|
16
|
+
const element = documentRef?.querySelector?.(`meta[name="${name}"]`);
|
|
17
|
+
return element?.getAttribute?.("content")?.trim() || "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readEnv(env, key) {
|
|
21
|
+
return typeof env?.[key] === "string" ? env[key].trim() : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeOrigin(value, fallback) {
|
|
25
|
+
if (!value) return fallback;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(value);
|
|
29
|
+
if (
|
|
30
|
+
!["http:", "https:"].includes(url.protocol) ||
|
|
31
|
+
url.username ||
|
|
32
|
+
url.password ||
|
|
33
|
+
url.pathname !== "/" ||
|
|
34
|
+
url.search ||
|
|
35
|
+
url.hash
|
|
36
|
+
) {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
return url.origin;
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isLocalOrigin(origin) {
|
|
46
|
+
try {
|
|
47
|
+
return LOCAL_HOSTNAMES.has(new URL(origin).hostname);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stripTrailingSlashes(pathname) {
|
|
54
|
+
let end = pathname.length;
|
|
55
|
+
while (end > 0 && pathname[end - 1] === "/") end -= 1;
|
|
56
|
+
return pathname.slice(0, end) || "/";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizePath(value, fallback = "/") {
|
|
60
|
+
const candidate = String(value || "").trim();
|
|
61
|
+
if (!candidate?.startsWith("/")) return fallback;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(candidate, "https://shorten-url.invalid");
|
|
65
|
+
if (url.search || url.hash) return fallback;
|
|
66
|
+
return stripTrailingSlashes(url.pathname);
|
|
67
|
+
} catch {
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function nextWorkspaceTab(activeTab, key) {
|
|
73
|
+
if (key === "Home") return WORKSPACE_TABS[0].id;
|
|
74
|
+
if (key === "End") return WORKSPACE_TABS.at(-1).id;
|
|
75
|
+
if (!["ArrowLeft", "ArrowRight"].includes(key)) return null;
|
|
76
|
+
|
|
77
|
+
const currentIndex = WORKSPACE_TABS.findIndex(({ id }) => id === activeTab);
|
|
78
|
+
const direction = key === "ArrowRight" ? 1 : -1;
|
|
79
|
+
const nextIndex = (currentIndex + direction + WORKSPACE_TABS.length) % WORKSPACE_TABS.length;
|
|
80
|
+
return WORKSPACE_TABS[nextIndex].id;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createFrontendConfig({
|
|
84
|
+
windowRef = typeof window === "undefined" ? undefined : window,
|
|
85
|
+
documentRef = typeof document === "undefined" ? undefined : document,
|
|
86
|
+
env = import.meta.env || {},
|
|
87
|
+
} = {}) {
|
|
88
|
+
const currentOrigin = windowRef?.location?.origin || FALLBACK_ORIGIN;
|
|
89
|
+
const configuredOrigin = firstNonEmpty(
|
|
90
|
+
readEnv(env, "VITE_WORKER_ORIGIN"),
|
|
91
|
+
readMeta(documentRef, "shorten-url-worker-origin")
|
|
92
|
+
);
|
|
93
|
+
const pathname = windowRef?.location?.pathname || "/";
|
|
94
|
+
const currentPath = normalizePath(pathname, "/");
|
|
95
|
+
const isDevRoute = currentPath === "/shorten";
|
|
96
|
+
const defaultHomePath = isDevRoute ? "/shorten" : "/";
|
|
97
|
+
const homePath = normalizePath(
|
|
98
|
+
firstNonEmpty(
|
|
99
|
+
readEnv(env, "VITE_WORKER_HOME_PATH"),
|
|
100
|
+
readMeta(documentRef, "shorten-url-home-path")
|
|
101
|
+
),
|
|
102
|
+
defaultHomePath
|
|
103
|
+
);
|
|
104
|
+
const settingsPath = normalizePath(
|
|
105
|
+
firstNonEmpty(
|
|
106
|
+
readEnv(env, "VITE_WORKER_SETTINGS_PATH"),
|
|
107
|
+
readMeta(documentRef, "shorten-url-settings-path")
|
|
108
|
+
),
|
|
109
|
+
DEFAULT_SETTINGS_PATH
|
|
110
|
+
);
|
|
111
|
+
const shortenApiPath = normalizePath(
|
|
112
|
+
firstNonEmpty(
|
|
113
|
+
readEnv(env, "VITE_WORKER_SHORTEN_PATH"),
|
|
114
|
+
readMeta(documentRef, "shorten-url-shorten-path")
|
|
115
|
+
),
|
|
116
|
+
DEFAULT_SHORTEN_PATH
|
|
117
|
+
);
|
|
118
|
+
const workerOrigin = normalizeOrigin(configuredOrigin, currentOrigin);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
workerOrigin,
|
|
122
|
+
workerOriginConfigured: Boolean(configuredOrigin),
|
|
123
|
+
isLocalDevelopment: isLocalOrigin(currentOrigin) && !configuredOrigin,
|
|
124
|
+
homePath,
|
|
125
|
+
settingsPath,
|
|
126
|
+
shortenApiPath,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function workerUrl(path, config) {
|
|
131
|
+
const workerOrigin = config?.workerOrigin || FALLBACK_ORIGIN;
|
|
132
|
+
const normalizedPath = normalizePath(path, "/");
|
|
133
|
+
return new URL(normalizedPath, `${workerOrigin}/`).href;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function settingsApiUrl(endpoint, config) {
|
|
137
|
+
const suffix = String(endpoint || "").startsWith("/") ? endpoint : `/${endpoint}`;
|
|
138
|
+
return workerUrl(`${config.settingsPath}/api${suffix}`, config);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class WorkerApiError extends Error {
|
|
142
|
+
constructor(message, status, body) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = "WorkerApiError";
|
|
145
|
+
this.status = status;
|
|
146
|
+
this.body = body;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function errorMessage(body, status, fallback = "The request could not be completed") {
|
|
151
|
+
let message = "";
|
|
152
|
+
if (typeof body?.message === "string") {
|
|
153
|
+
message = body.message.trim();
|
|
154
|
+
} else if (typeof body?.error === "string") {
|
|
155
|
+
message = body.error.trim();
|
|
156
|
+
}
|
|
157
|
+
if (message) return message;
|
|
158
|
+
return status ? `${fallback} (${status})` : fallback;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readResponseBody(response) {
|
|
162
|
+
if (typeof response?.text === "function") {
|
|
163
|
+
const raw = await response.text();
|
|
164
|
+
if (!raw) return {};
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
return { message: raw.slice(0, 240) };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (typeof response?.json === "function") return response.json();
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function requestJson(
|
|
176
|
+
url,
|
|
177
|
+
{ fetchImpl = globalThis.fetch, method = "GET", body, csrfToken } = {}
|
|
178
|
+
) {
|
|
179
|
+
if (typeof fetchImpl !== "function") throw new Error("Fetch is not available");
|
|
180
|
+
|
|
181
|
+
const headers = { Accept: "application/json" };
|
|
182
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
183
|
+
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
|
184
|
+
|
|
185
|
+
const response = await fetchImpl(url, {
|
|
186
|
+
method,
|
|
187
|
+
headers,
|
|
188
|
+
credentials: "include",
|
|
189
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
190
|
+
});
|
|
191
|
+
const responseBody = await readResponseBody(response);
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
throw new WorkerApiError(
|
|
194
|
+
errorMessage(responseBody, response.status),
|
|
195
|
+
response.status,
|
|
196
|
+
responseBody
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return responseBody;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function shortenUrl({ url, customSlug }, config, fetchImpl = globalThis.fetch) {
|
|
203
|
+
const payload = { url: url.trim() };
|
|
204
|
+
if (customSlug?.trim()) payload.custom_slug = customSlug.trim();
|
|
205
|
+
|
|
206
|
+
const result = await requestJson(workerUrl(config.shortenApiPath, config), {
|
|
207
|
+
fetchImpl,
|
|
208
|
+
method: "POST",
|
|
209
|
+
body: payload,
|
|
210
|
+
});
|
|
211
|
+
if (typeof result?.short_url !== "string" || !result.short_url) {
|
|
212
|
+
throw new WorkerApiError("The Worker returned an invalid short URL", 502, result);
|
|
213
|
+
}
|
|
214
|
+
return result.short_url;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function loadSettings(config, fetchImpl = globalThis.fetch) {
|
|
218
|
+
const [csrf, flags] = await Promise.all([
|
|
219
|
+
requestJson(settingsApiUrl("/csrf", config), { fetchImpl }),
|
|
220
|
+
requestJson(settingsApiUrl("/flags", config), { fetchImpl }),
|
|
221
|
+
]);
|
|
222
|
+
if (typeof csrf?.csrfToken !== "string" || !csrf.csrfToken) {
|
|
223
|
+
throw new WorkerApiError("The settings session could not be established", 502, csrf);
|
|
224
|
+
}
|
|
225
|
+
const flag = Array.isArray(flags?.flags)
|
|
226
|
+
? flags.flags.find((candidate) => candidate?.key === "shorten-routing") || null
|
|
227
|
+
: null;
|
|
228
|
+
return { csrfToken: csrf.csrfToken, flag };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function createShorteningFlag(
|
|
232
|
+
definition,
|
|
233
|
+
config,
|
|
234
|
+
csrfToken,
|
|
235
|
+
fetchImpl = globalThis.fetch
|
|
236
|
+
) {
|
|
237
|
+
return requestJson(settingsApiUrl("/flag", config), {
|
|
238
|
+
fetchImpl,
|
|
239
|
+
method: "POST",
|
|
240
|
+
csrfToken,
|
|
241
|
+
body: definition,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function updateShorteningFlag(
|
|
246
|
+
definition,
|
|
247
|
+
expectedUpdatedAt,
|
|
248
|
+
config,
|
|
249
|
+
csrfToken,
|
|
250
|
+
fetchImpl = globalThis.fetch
|
|
251
|
+
) {
|
|
252
|
+
return requestJson(settingsApiUrl("/flag/shorten-routing", config), {
|
|
253
|
+
fetchImpl,
|
|
254
|
+
method: "PUT",
|
|
255
|
+
csrfToken,
|
|
256
|
+
body: { ...definition, expectedUpdatedAt },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function publishShorteningFlag(
|
|
261
|
+
expectedUpdatedAt,
|
|
262
|
+
config,
|
|
263
|
+
csrfToken,
|
|
264
|
+
fetchImpl = globalThis.fetch
|
|
265
|
+
) {
|
|
266
|
+
return requestJson(settingsApiUrl("/flag/shorten-routing/publish", config), {
|
|
267
|
+
fetchImpl,
|
|
268
|
+
method: "POST",
|
|
269
|
+
csrfToken,
|
|
270
|
+
body: { expectedUpdatedAt },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const STORAGE_KEY = "shorten-url:flagship-local-mock:v1";
|
|
2
|
+
|
|
3
|
+
function storage() {
|
|
4
|
+
try {
|
|
5
|
+
if (!globalThis.localStorage) throw new Error("Local storage is unavailable");
|
|
6
|
+
return globalThis.localStorage;
|
|
7
|
+
} catch {
|
|
8
|
+
throw new Error("Browser storage is unavailable for the local Flagship mock.");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readFlag() {
|
|
13
|
+
const serialized = storage().getItem(STORAGE_KEY);
|
|
14
|
+
if (!serialized) return null;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const flag = JSON.parse(serialized);
|
|
18
|
+
return flag && typeof flag === "object" && !Array.isArray(flag) ? flag : null;
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error("The local Flagship mock data could not be read.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeFlag(flag) {
|
|
25
|
+
try {
|
|
26
|
+
storage().setItem(STORAGE_KEY, JSON.stringify(flag));
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error("The local Flagship mock could not be saved.");
|
|
29
|
+
}
|
|
30
|
+
return { flag };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadLocalFlagMock() {
|
|
34
|
+
return readFlag();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function saveLocalFlagMock(definition) {
|
|
38
|
+
return writeFlag({ ...definition, status: "draft", updatedAt: new Date().toISOString() });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function publishLocalFlagMock(expectedUpdatedAt) {
|
|
42
|
+
const current = readFlag();
|
|
43
|
+
if (!current) throw new Error("Save a local mock draft before publishing.");
|
|
44
|
+
if (current.updatedAt !== expectedUpdatedAt) {
|
|
45
|
+
throw new Error("The local mock changed. Reload it before publishing.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const timestamp = new Date().toISOString();
|
|
49
|
+
return writeFlag({
|
|
50
|
+
...current,
|
|
51
|
+
status: "published",
|
|
52
|
+
updatedAt: timestamp,
|
|
53
|
+
publishedAt: timestamp,
|
|
54
|
+
});
|
|
55
|
+
}
|