Files
windmill/frontend/src/lib/hubProject.ts
T
Diego Imbert 47525b211a perf: shrink the module graph that gates first paint in dev (#11373)
* perf: shrink the module graph that gates first paint in dev

* docs: drop the stale synchronous-icons claim on the import card

* fix: replay search opened before its modal loads, guard lazy icons

* fix: only intercept search before load where the modal mounts

* perf: mount app-shell modals on first open and keep monaco off the shell

* perf: load the icon map on first read, not at module evaluation

* fix: report stale chunks with a reload toast, guard the home page against monaco
2026-09-27 10:09:45 +00:00

244 lines
8.9 KiB
TypeScript

import type { Component } from 'svelte'
import { appIconComponent } from '$lib/components/icons/appIcon.svelte'
import { HubPublishService, SettingService } from '$lib/gen'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
/**
* The browser-reachable hub. `hub_accessible_url` exists precisely for this: on a
* private instance `hub_base_url` may be an address only the server can resolve.
* The (logged) layout does the same lookup, but the import wizard renders outside
* it, so it has to ask for itself.
*/
export async function hubBrowserUrl(): Promise<string> {
try {
const accessible = (await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string
if (accessible) return accessible.replace(/\/+$/, '')
const base = (await SettingService.getGlobal({ key: 'hub_base_url' })) as string
if (base) return base.replace(/\/+$/, '')
} catch {
// Unset or unreadable — the public hub is the right default either way.
}
return DEFAULT_HUB_BASE_URL.replace(/\/+$/, '')
}
/** Shape of `GET <hub>/projects/<slug>` — the hub's own summary endpoint. */
interface HubProject {
slug: string
name: string
summary: string
author: string
apps: string[]
logoApp: string | null
hasLogo: boolean
counts: { scripts: number; flows: number; apps: number; resources: number; total: number }
}
/**
* Fetches one project's presentation straight from the hub. Cross-origin and
* unauthenticated by design: this runs before the wizard has a workspace, so the
* workspace-scoped `/api/w/<ws>/hub/...` proxy is not available yet. The endpoint
* is public and sends `Access-Control-Allow-Origin: *`.
*/
export async function fetchHubProject(slug: string): Promise<ImportProjectSummary> {
const hub = await hubBrowserUrl()
const res = await fetch(`${hub}/projects/${encodeURIComponent(slug)}`, {
headers: { accept: 'application/json' }
})
if (!res.ok) throw new Error(`hub returned ${res.status}`)
const p = (await res.json()) as HubProject
return {
slug: p.slug,
name: p.name,
summary: p.summary,
author: p.author,
apps: p.apps ?? [],
// A project with an uploaded logo shows that; otherwise the icon of the
// integration it is filed under, otherwise its first integration.
logoUrl: p.hasLogo ? `${hub}/projects/${encodeURIComponent(p.slug)}/logo` : undefined,
iconApps: [p.logoApp, ...(p.apps ?? [])].filter(
(a, i, all): a is string => !!a && all.indexOf(a) === i
),
counts: {
apps: p.counts?.apps ?? 0,
flows: p.counts?.flows ?? 0,
scripts: p.counts?.scripts ?? 0,
resources: p.counts?.resources ?? 0
}
}
}
/**
* The icon for a hub integration slug, resolved from the icons Windmill already bundles.
*
* Not fetched from the hub: the hub renders these out of `@windmill-labs/components`, which
* is this frontend's own package, so asking it over HTTP is a round trip to get our own
* assets back — and it made the card depend on a cross-origin request that an `API_SECRET`
* hub refuses anyway.
*
* The alias exists because the two repos disagree on one slug: the hub files Postgres scripts
* under `postgres`, the icon set ships the mark as `postgresql`. The hub bridges it in
* `aliasApp`; this is the same bridge on the consuming side.
*/
const HUB_APP_ICON_ALIAS: Record<string, string> = { postgres: 'postgresql' }
export function hubAppIcon(app: string): Component | undefined {
return appIconComponent(HUB_APP_ICON_ALIAS[app] ?? app)
}
/** One row of the hub's catalogue (`GET <hub>/projects`), which carries no item counts. */
interface HubProjectListRow {
slug: string
name: string
summary: string
description: string
readme: string
author: string
apps: string[]
hasLogo: boolean
stars: number
}
const DESCRIPTION_MAX = 320
/**
* What a project says about itself, in prose.
*
* The hub's `description` field is empty on every published project — the writing all
* goes in the readme — so the readme's opening paragraphs stand in. Everything from the
* first heading onwards is dropped: that is the "Windmill concepts demonstrated" /
* "Usage" material, which is documentation rather than a description. A readme that
* *starts* with a heading (`## Description`) has it skipped rather than treated as the
* end of the intro.
*/
export function hubProjectDescription(row: {
description?: string
readme?: string
summary?: string
}): string {
if (row.description?.trim()) return row.description.trim()
const lines = (row.readme ?? '').split('\n')
let i = 0
while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('#'))) i++
const intro: string[] = []
for (; i < lines.length; i++) {
if (lines[i].startsWith('#')) break
intro.push(lines[i])
}
const text = intro
.join(' ')
// Inline markdown only — the block syntax is already gone with the headings.
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`]/g, '')
.replace(/\s+/g, ' ')
.trim()
if (!text) return row.summary?.trim() ?? ''
if (text.length <= DESCRIPTION_MAX) return text
// Cut on a word boundary: a description sliced mid-word reads as corrupted rather
// than shortened.
const cut = text.slice(0, DESCRIPTION_MAX)
const lastSpace = cut.lastIndexOf(' ')
return `${(lastSpace > DESCRIPTION_MAX * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`
}
/**
* A card in the template picker. Everything it shows comes from the catalogue listing,
* so a whole page of cards costs one request; the item counts, which only the import
* step needs, are fetched per project by `fetchHubProject` when one is picked.
* `id` is what `InfiniteList` dedupes rows by.
*/
export interface HubProjectPick {
id: string
slug: string
name: string
summary: string
description: string
author: string
apps: string[]
logoUrl?: string
iconApps: string[]
stars: number
}
let catalogue: { workspace: string; projects: Promise<HubProjectPick[]> } | undefined
/**
* Every published project, most-starred first, fetched once per workspace and held for
* the life of the page.
*
* Through the workspace-scoped proxy rather than straight at the hub the way
* `fetchHubProject` goes: the catalogue endpoint sends no `Access-Control-Allow-Origin`,
* so the browser cannot read it directly.
*/
export function hubProjectCatalogue(workspace: string): Promise<HubProjectPick[]> {
if (catalogue?.workspace !== workspace) {
const projects = loadCatalogue(workspace).catch((e) => {
// A cached rejection would make the failure permanent for the whole session;
// dropping it lets the next open try again.
if (catalogue?.projects === projects) catalogue = undefined
throw e
})
catalogue = { workspace, projects }
}
return catalogue.projects
}
async function loadCatalogue(workspace: string): Promise<HubProjectPick[]> {
const raw = await HubPublishService.listHubProjects({ workspace })
const rows = ((typeof raw === 'string' ? JSON.parse(raw) : raw)?.projects ??
[]) as HubProjectListRow[]
const hub = await hubBrowserUrl()
return rows
.map((row) => ({
id: row.slug,
slug: row.slug,
name: row.name,
summary: row.summary,
description: hubProjectDescription(row),
author: row.author,
apps: row.apps ?? [],
logoUrl: row.hasLogo ? `${hub}/projects/${encodeURIComponent(row.slug)}/logo` : undefined,
iconApps: row.apps ?? [],
stars: row.stars ?? 0
}))
.sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name))
}
/** How many tool-matched projects to hoist when the invite named tools but no projects. */
const TOOL_PICKS_MAX = 3
/**
* The catalogue with the projects an invite picked for this person moved to the front, in
* the invite's order, each flagged so the row can say why it is there. Named slugs win; when
* none are named but the person's tools are known, the most-starred projects built on one
* of those tools stand in. Anything the hub no longer lists is skipped, so a stale invite
* costs nothing. `pinned` is set on every row rather than left off the rest: the list
* renders from it, and a missing flag would read as "not pinned" only by accident.
*/
export function pinHubProjects(
all: HubProjectPick[],
picks: { hub_projects?: string[]; tools?: string[] } | null | undefined
): (HubProjectPick & { pinned: boolean })[] {
let pinnedSlugs: string[] = []
if (picks?.hub_projects?.length) {
const known = new Set(all.map((p) => p.slug))
pinnedSlugs = picks.hub_projects.filter((s) => known.has(s))
} else if (picks?.tools?.length) {
const tools = new Set(picks.tools)
pinnedSlugs = all
.filter((p) => p.apps.some((a) => tools.has(a.toLowerCase())))
.slice(0, TOOL_PICKS_MAX)
.map((p) => p.slug)
}
if (!pinnedSlugs.length) return all.map((p) => ({ ...p, pinned: false }))
const bySlug = new Map(all.map((p) => [p.slug, p]))
const pinned = pinnedSlugs.map((s) => ({ ...bySlug.get(s)!, pinned: true }))
const pinnedSet = new Set(pinnedSlugs)
return [
...pinned,
...all.filter((p) => !pinnedSet.has(p.slug)).map((p) => ({ ...p, pinned: false }))
]
}