spoosh-angular — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited spoosh-angular (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Spoosh is a type-safe API toolkit with a composable plugin architecture for TypeScript. This skill covers the Angular integration using signals including inject functions API, plugins, and component patterns.
pnpm add @spoosh/core @spoosh/angularimport { Spoosh } from "@spoosh/core";
import { create } from "@spoosh/angular";
type ApiSchema = {
users: {
GET: { data: User[] };
POST: { data: User; body: CreateUserBody };
};
"users/:id": {
GET: { data: User };
DELETE: { data: void };
};
};
const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
cachePlugin(),
retryPlugin(),
]);
export const { injectRead, injectWrite, injectPages, injectQueue } =
create(spoosh);Fetch data with automatic caching and state management. Returns signals.
const users = injectRead((api) => api("users").GET(), {
staleTime: 30000,
enabled: true,
});Returns (all signals except methods): data, loading, fetching, error, trigger(), abort(), meta
Options: enabled (supports Signal or function), tags, staleTime, retry, pollingInterval, refetch, debounce, transform, initialData
Perform mutations (POST, PUT, DELETE). Returns signals.
const createUser = injectWrite((api) => api("users").POST());
// Invalidation is AUTOMATIC - no need to specify in most cases!
await createUser.trigger({ body: { name, email } });Returns (all signals except methods): trigger(), loading, error, data, meta, input, abort()
Trigger options: body, params, query, headers, invalidate, clearCache, optimistic
Bidirectional pagination with infinite scroll. Returns signals.
const posts = injectPages((api) => api("posts").GET({ query: { page: 1 } }), {
canFetchNext: ({ lastPage }) => lastPage?.data?.hasMore ?? false,
nextPageRequest: ({ lastPage, request }) => ({
query: { ...request.query, page: (lastPage?.data?.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
});Returns (all signals except methods): data, pages, loading, fetchingNext, canFetchNext, fetchNext(), fetchPrev(), trigger()
Queue management for batch operations with concurrency control.
const uploadQueue = injectQueue((api) => api("files").POST(), {
concurrency: 3,
});
files.forEach((file) => uploadQueue.trigger({ body: form({ file }) }));Returns (signals for tasks and stats): tasks, stats, trigger(), abort(), retry(), remove(), removeSettled(), clear(), setConcurrency()
Stats: pending, loading, settled, success, failed, total, percentage
| Plugin | Purpose | Key Options |
|---|---|---|
cachePlugin | Response caching | staleTime |
retryPlugin | Automatic retries | retry: { retries, delay } |
pollingPlugin | Auto-refresh | pollingInterval |
invalidationPlugin | Cache invalidation | invalidate |
optimisticPlugin | Instant UI updates + standalone optimistic fn | optimistic |
debouncePlugin | Debounce requests | debounce |
refetchPlugin | Refetch on focus | refetch: { onFocus, onReconnect } |
IMPORTANT: Tags and invalidation are handled automatically! You rarely need to configure them manually.
api("users/:id").GET({ params: { id: 123 } }) → tag: "users/123")[firstSegment, firstSegment/*] pattern// ✅ RECOMMENDED: Let Spoosh handle it automatically
await createUser.trigger({ body: data });
// POST users/123 → auto-invalidates ["users", "users/*"]
// Only override when you need specific behavior:
await createUser.trigger({ body: data, invalidate: "posts" }); // Exact match only
await createUser.trigger({ body: data, invalidate: "posts/*" }); // Children only
await createUser.trigger({ body: data, invalidate: ["posts", "users"] }); // Multiple patterns
await createUser.trigger({ body: data, invalidate: false }); // Disable invalidation
await createUser.trigger({ body: data, invalidate: "*" }); // Global refetch allCustom tags on queries (when auto-generated tag doesn't fit your needs):
// Custom tag only - replaces auto-generated tag
users = injectRead((api) => api("users").GET(), {
tags: "dashboard-users",
});
// Default path + custom tags (recommended for cross-cutting concerns)
stats = injectRead((api) => api("stats").GET(), {
tags: ["stats", "sidebar-stats"], // Keep "stats" for auto-invalidation + add custom
});
// Then invalidate by either:
await createUser.trigger({ body: data, invalidate: "stats" }); // Path-based (auto works)
await createUser.trigger({ body: data, invalidate: "sidebar-stats" }); // Custom tagGroups config for namespaced APIs (e.g., admin/posts, api/v1/users):
invalidationPlugin({
groups: ["admin", "api/v1"], // These prefixes use deeper segment matching
});
// POST admin/posts → invalidates ["admin/posts", "admin/posts/*"] instead of ["admin", "admin/*"]@Component({
selector: "app-user-list",
template: `
@if (users.loading()) {
<app-user-list-skeleton />
} @else if (users.error()) {
<app-error-message [error]="users.error()!" (retry)="users.trigger()" />
} @else if (!users.data()?.length) {
<app-empty-state message="No users found" />
} @else {
<ul>
@for (user of users.data(); track user.id) {
<app-user-card [user]="user" />
}
</ul>
}
`,
})
export class UserListComponent {
users = injectRead((api) => api("users").GET(), { staleTime: 30000 });
}@Component({
selector: "app-create-user-form",
template: `
<form (ngSubmit)="handleSubmit()">
<input [(ngModel)]="name" name="name" [disabled]="createUser.loading()" />
@if (createUser.error()) {
<p class="error">{{ createUser.error()!.message }}</p>
}
<button [disabled]="createUser.loading()">
{{ createUser.loading() ? "Creating..." : "Create" }}
</button>
</form>
`,
})
export class CreateUserFormComponent {
name = "";
createUser = injectWrite((api) => api("users").POST());
async handleSubmit() {
// No need to specify invalidate - it's automatic!
const result = await this.createUser.trigger({ body: { name: this.name } });
if (result.data) this.name = "";
}
}@Component({
selector: "app-infinite-post-list",
template: `
@if (posts.loading()) {
<app-post-list-skeleton />
} @else {
<div>
@for (post of posts.data(); track post.id) {
<app-post-card [post]="post" />
}
<div #loadTrigger>
@if (posts.fetchingNext()) {
<app-loading-spinner />
}
</div>
</div>
}
`,
})
export class InfinitePostListComponent {
loadTrigger = viewChild<ElementRef>("loadTrigger");
posts = injectPages(
(api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.hasMore ?? false,
nextPageRequest: ({ lastPage, request }) => ({
query: { ...request.query, page: (lastPage?.data?.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);
private observer?: IntersectionObserver;
constructor() {
effect(() => {
const element = this.loadTrigger()?.nativeElement;
if (element) {
this.observer?.disconnect();
this.observer = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
this.posts.canFetchNext() &&
!this.posts.fetchingNext()
) {
this.posts.fetchNext();
}
},
{ threshold: 0.1 }
);
this.observer.observe(element);
}
});
}
}@Component({
selector: "app-toggle-like-button",
template: `
<button (click)="handleToggle()">
{{ liked() ? "Unlike" : "Like" }} ({{ likeCount() }})
</button>
`,
})
export class ToggleLikeButtonComponent {
postId = input.required<string>();
liked = input.required<boolean>();
likeCount = input.required<number>();
toggleLike = injectWrite((api) => api("posts/:id/like").POST());
handleToggle() {
this.toggleLike.trigger({
params: { id: this.postId() },
optimistic: (cache) =>
cache(`posts/${this.postId()}`).set((current) => ({
...current,
liked: !this.liked(),
likeCount: this.liked() ? this.likeCount() - 1 : this.likeCount() + 1,
})),
});
}
}@Component({
selector: "app-search-users",
template: `
<div>
<input
[(ngModel)]="query"
(ngModelChange)="searchQuery.set($event)"
placeholder="Search..."
/>
@if (searchResults.fetching()) {
<app-loading-indicator />
}
@for (user of searchResults.data(); track user.id) {
<li>{{ user.name }}</li>
}
</div>
`,
})
export class SearchUsersComponent {
query = "";
searchQuery = signal("");
searchResults = injectRead(
(api) => api("users/search").GET({ query: { q: this.searchQuery() } }),
{ enabled: () => this.searchQuery().length >= 2, debounce: 300 }
);
}@Component({
selector: "app-job-status",
template: `<p>Status: {{ job.data()?.status }}</p>`,
})
export class JobStatusComponent {
jobId = input.required<string>();
job = injectRead(
(api) => api("jobs/:id").GET({ params: { id: this.jobId() } }),
{
pollingInterval: ({ data }) => {
if (data?.status === "completed" || data?.status === "failed")
return false;
return 2000;
},
}
);
}import { Spoosh, StripPrefix } from "@spoosh/core";
import type { HonoToSpoosh } from "@spoosh/hono";
// Server: app.basePath("/api")
type FullSchema = HonoToSpoosh<typeof app>;
type ApiSchema = StripPrefix<FullSchema, "api">; // Avoid double /api/api
const spoosh = new Spoosh<ApiSchema, Error>("/api");import { Spoosh, StripPrefix } from "@spoosh/core";
import type { ElysiaToSpoosh } from "@spoosh/elysia";
// Server: new Elysia({ prefix: "/api" })
type FullSchema = ElysiaToSpoosh<typeof app>;
type ApiSchema = StripPrefix<FullSchema, "api">; // Avoid double /api/api
const spoosh = new Spoosh<ApiSchema, Error>("/api");Use StripPrefix when your baseUrl includes the same prefix as the server's basePath to prevent double prefixing (e.g., /api/api/users).
# Export TypeScript → OpenAPI
npx spoosh-openapi export --schema ./schema.ts --output openapi.json
# Import OpenAPI → TypeScript
npx spoosh-openapi import openapi.json --output ./schema.tsFor detailed API documentation:
references/signals-api.md - Complete inject function signaturesreferences/plugins-api.md - All plugin configurationsreferences/advanced-patterns.md - Complex patterns and edge casesIf more detail needed, fetch https://spoosh.dev/docs/angular/llms (or /llms-full for complete docs).
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.