@portalshq/capability-queue-broadcast 0.1.5 → 0.1.9
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 +12 -3
- package/dist/client.d.ts +2 -1
- package/dist/client.js +8 -2
- package/dist/content-pipeline.d.ts +66 -0
- package/dist/content-pipeline.js +231 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Server-only producer API for one isolated Queue Broadcast Server endpoint.
|
|
4
4
|
|
|
5
|
+
`ContentQueuePipeline` is the reusable producer preparation boundary. It
|
|
6
|
+
deduplicates channel/run/content identities, bounds concurrent preparation and
|
|
7
|
+
measured duration/bytes/count, accounts for shared visuals once, commits
|
|
8
|
+
results in enqueue order, and exposes staging/release/completion acknowledgments.
|
|
9
|
+
Applications provide content preparation and persistence callbacks.
|
|
10
|
+
|
|
5
11
|
The queue is deliberately separate from playback: a trusted application backend
|
|
6
12
|
uses this package to enqueue and observe jobs; its browser player gets only the
|
|
7
13
|
public HLS manifest returned by `getPlayback()`. Use
|
|
@@ -103,14 +109,17 @@ webhooks, storage, and relays remain outside this capability.
|
|
|
103
109
|
|
|
104
110
|
## Generated API
|
|
105
111
|
|
|
106
|
-
`src/generated/api.ts` is generated from the
|
|
107
|
-
`openapi.json` contract using Orval:
|
|
112
|
+
`src/generated/api.ts` is generated from the checked-in
|
|
113
|
+
`openapi/streamer.json` contract snapshot using Orval:
|
|
108
114
|
|
|
109
115
|
```bash
|
|
110
116
|
npm run generate -w @portalshq/capability-queue-broadcast
|
|
111
117
|
```
|
|
112
118
|
|
|
113
|
-
|
|
119
|
+
Refresh `openapi/streamer.json` from the Streamer repository when updating the
|
|
120
|
+
client, and commit the snapshot with the generated changes. This makes release
|
|
121
|
+
tests independent of a sibling checkout. The current snapshot comes from
|
|
122
|
+
Streamer commit `90b9102` (`openapi.json`). The
|
|
114
123
|
hand-written `QueueBroadcastClient` owns authentication, error mapping,
|
|
115
124
|
endpoint validation, and polling semantics.
|
|
116
125
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CaptionTrack, type HlsPlaybackSession } from "@portalshq/capability-video-delivery";
|
|
2
2
|
import type { ChatMessage } from "@portalshq/capability-realtime-fanout";
|
|
3
3
|
import type { HealthResponse, JobResponse, JobResponseMediaType, JobResponseStatus, QueuePairResponse, QueueSlotResponse } from "./generated/api.js";
|
|
4
4
|
export type QueueMediaType = JobResponseMediaType;
|
|
@@ -165,6 +165,7 @@ export declare class QueueBroadcastClient {
|
|
|
165
165
|
watchJob(jobId: string, options?: WatchJobOptions): AsyncGenerator<QueueBroadcastJob>;
|
|
166
166
|
getPlayback(options?: {
|
|
167
167
|
signal?: AbortSignal;
|
|
168
|
+
captionTracks?: readonly CaptionTrack[];
|
|
168
169
|
}): Promise<HlsPlaybackSession>;
|
|
169
170
|
health(options?: {
|
|
170
171
|
signal?: AbortSignal;
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeCaptionTracks } from "@portalshq/capability-video-delivery";
|
|
1
2
|
/** Error returned by the queue control plane, including its HTTP status. */
|
|
2
3
|
export class QueueBroadcastError extends Error {
|
|
3
4
|
status;
|
|
@@ -240,12 +241,17 @@ export class QueueBroadcastClient {
|
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
243
|
async getPlayback(options = {}) {
|
|
243
|
-
const
|
|
244
|
+
const { captionTracks, ...requestOptions } = options;
|
|
245
|
+
const stream = await this.request("/v1/stream", requestOptions);
|
|
244
246
|
const hls = new URL(stream.hls);
|
|
245
247
|
if (hls.protocol !== "http:" && hls.protocol !== "https:") {
|
|
246
248
|
throw new QueueBroadcastError("queue server returned a non-http HLS URL", 502);
|
|
247
249
|
}
|
|
248
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
sessionId: this.endpoint,
|
|
252
|
+
playbackManifestUrl: hls.toString(),
|
|
253
|
+
...(captionTracks === undefined ? {} : { captionTracks: normalizeCaptionTracks(captionTracks) }),
|
|
254
|
+
};
|
|
249
255
|
}
|
|
250
256
|
async health(options = {}) {
|
|
251
257
|
return this.request("/health", options, false);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type ContentQueueState = "queued" | "preparing" | "prepared" | "staged" | "released" | "completed" | "terminal-failed" | "cancelled";
|
|
2
|
+
export interface ContentQueueIdentity {
|
|
3
|
+
channelId: string;
|
|
4
|
+
runEpoch: string;
|
|
5
|
+
contentKey: string;
|
|
6
|
+
ordinal: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ContentCapacity {
|
|
9
|
+
durationMs: number;
|
|
10
|
+
bytes: number;
|
|
11
|
+
contentCount: number;
|
|
12
|
+
visualKey?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ContentQueueLimits {
|
|
15
|
+
maxInFlight: number;
|
|
16
|
+
maxDurationMs?: number;
|
|
17
|
+
maxBytes?: number;
|
|
18
|
+
maxContentCount?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ContentQueueObservation {
|
|
21
|
+
identity: ContentQueueIdentity;
|
|
22
|
+
state: ContentQueueState;
|
|
23
|
+
sequence: number;
|
|
24
|
+
error?: unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface ContentQueuePipelineOptions<TRequest, TPrepared> {
|
|
27
|
+
limits: ContentQueueLimits;
|
|
28
|
+
identity(request: TRequest): ContentQueueIdentity;
|
|
29
|
+
estimate(request: TRequest): ContentCapacity;
|
|
30
|
+
measure(prepared: TPrepared, request: TRequest): ContentCapacity;
|
|
31
|
+
prepare(request: TRequest, signal: AbortSignal): Promise<TPrepared>;
|
|
32
|
+
/** Commits completions in enqueue order, even when preparation finishes out of order. */
|
|
33
|
+
commit(observation: ContentQueueObservation, prepared: TPrepared | undefined, request: TRequest): Promise<void>;
|
|
34
|
+
onObservation?: (observation: ContentQueueObservation) => void;
|
|
35
|
+
}
|
|
36
|
+
export interface ContentQueueTicket<TPrepared> {
|
|
37
|
+
identity: ContentQueueIdentity;
|
|
38
|
+
completion: Promise<TPrepared>;
|
|
39
|
+
}
|
|
40
|
+
/** Bounded, ordered preparation for queue-broadcast producers. */
|
|
41
|
+
export declare class ContentQueuePipeline<TRequest, TPrepared> {
|
|
42
|
+
private readonly options;
|
|
43
|
+
private readonly work;
|
|
44
|
+
private readonly pending;
|
|
45
|
+
private nextSequence;
|
|
46
|
+
private nextCommitSequence;
|
|
47
|
+
private active;
|
|
48
|
+
private committing;
|
|
49
|
+
private stopped;
|
|
50
|
+
constructor(options: ContentQueuePipelineOptions<TRequest, TPrepared>);
|
|
51
|
+
enqueue(request: TRequest): ContentQueueTicket<TPrepared>;
|
|
52
|
+
/** Update queue lifecycle after preparation without creating another state owner. */
|
|
53
|
+
acknowledge(identity: ContentQueueIdentity, state: Extract<ContentQueueState, "staged" | "released" | "completed">): void;
|
|
54
|
+
snapshot(): readonly ContentQueueObservation[];
|
|
55
|
+
capacity(): ContentCapacity;
|
|
56
|
+
private capacityExcluding;
|
|
57
|
+
private assertMeasuredCapacity;
|
|
58
|
+
private assertCapacityAgainst;
|
|
59
|
+
stop(reason?: unknown): Promise<void>;
|
|
60
|
+
private assertCapacity;
|
|
61
|
+
private drain;
|
|
62
|
+
private run;
|
|
63
|
+
private commitReady;
|
|
64
|
+
private observe;
|
|
65
|
+
private toObservation;
|
|
66
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/** Bounded, ordered preparation for queue-broadcast producers. */
|
|
2
|
+
export class ContentQueuePipeline {
|
|
3
|
+
options;
|
|
4
|
+
work = new Map();
|
|
5
|
+
pending = [];
|
|
6
|
+
nextSequence = 1;
|
|
7
|
+
nextCommitSequence = 1;
|
|
8
|
+
active = 0;
|
|
9
|
+
committing = false;
|
|
10
|
+
stopped = false;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
const { limits } = options;
|
|
14
|
+
assertPositiveInteger("maxInFlight", limits.maxInFlight);
|
|
15
|
+
assertOptionalLimit("maxDurationMs", limits.maxDurationMs);
|
|
16
|
+
assertOptionalLimit("maxBytes", limits.maxBytes);
|
|
17
|
+
assertOptionalLimit("maxContentCount", limits.maxContentCount);
|
|
18
|
+
}
|
|
19
|
+
enqueue(request) {
|
|
20
|
+
if (this.stopped)
|
|
21
|
+
throw new Error("ContentQueuePipeline is stopped");
|
|
22
|
+
const identity = normalizeIdentity(this.options.identity(request));
|
|
23
|
+
const key = identityKey(identity);
|
|
24
|
+
const existing = this.work.get(key);
|
|
25
|
+
if (existing)
|
|
26
|
+
return { identity: existing.identity, completion: itemCompletion(existing) };
|
|
27
|
+
const estimate = normalizeCapacity(this.options.estimate(request));
|
|
28
|
+
this.assertCapacity(estimate);
|
|
29
|
+
let resolve;
|
|
30
|
+
let reject;
|
|
31
|
+
const completion = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
32
|
+
const item = {
|
|
33
|
+
sequence: this.nextSequence++, key, identity, request, estimate, state: "queued",
|
|
34
|
+
controller: new AbortController(), resolve, reject,
|
|
35
|
+
};
|
|
36
|
+
Object.defineProperty(item, "completion", { value: completion });
|
|
37
|
+
this.work.set(key, item);
|
|
38
|
+
this.pending.push(item);
|
|
39
|
+
this.observe(item);
|
|
40
|
+
this.drain();
|
|
41
|
+
return { identity, completion };
|
|
42
|
+
}
|
|
43
|
+
/** Update queue lifecycle after preparation without creating another state owner. */
|
|
44
|
+
acknowledge(identity, state) {
|
|
45
|
+
const item = this.work.get(identityKey(normalizeIdentity(identity)));
|
|
46
|
+
if (!item)
|
|
47
|
+
throw new Error("Unknown content queue identity");
|
|
48
|
+
const allowed = item.state === "prepared"
|
|
49
|
+
? state === "staged"
|
|
50
|
+
: item.state === "staged"
|
|
51
|
+
? state === "released"
|
|
52
|
+
: item.state === "released" && state === "completed";
|
|
53
|
+
if (!allowed)
|
|
54
|
+
throw new Error(`Invalid content queue transition ${item.state} -> ${state}`);
|
|
55
|
+
item.state = state;
|
|
56
|
+
this.observe(item);
|
|
57
|
+
}
|
|
58
|
+
snapshot() {
|
|
59
|
+
return [...this.work.values()].sort((a, b) => a.sequence - b.sequence).map((item) => this.toObservation(item));
|
|
60
|
+
}
|
|
61
|
+
capacity() {
|
|
62
|
+
return capacityOf([...this.work.values()].filter((item) => !isTerminal(item.state)));
|
|
63
|
+
}
|
|
64
|
+
capacityExcluding(excluded) {
|
|
65
|
+
return capacityOf([...this.work.values()].filter((item) => item !== excluded && !isTerminal(item.state)));
|
|
66
|
+
}
|
|
67
|
+
assertMeasuredCapacity(item, candidate) {
|
|
68
|
+
this.assertCapacityAgainst(this.capacityExcluding(item), candidate, item);
|
|
69
|
+
}
|
|
70
|
+
assertCapacityAgainst(current, candidate, excluded) {
|
|
71
|
+
const sharesVisual = candidate.visualKey && [...this.work.values()].some((item) => {
|
|
72
|
+
if (item === excluded || isTerminal(item.state))
|
|
73
|
+
return false;
|
|
74
|
+
return (item.actual ?? item.estimate).visualKey === candidate.visualKey;
|
|
75
|
+
});
|
|
76
|
+
const bytes = current.bytes + (sharesVisual ? 0 : candidate.bytes);
|
|
77
|
+
const { limits } = this.options;
|
|
78
|
+
if (limits.maxDurationMs !== undefined && current.durationMs + candidate.durationMs > limits.maxDurationMs)
|
|
79
|
+
throw capacityError("duration");
|
|
80
|
+
if (limits.maxBytes !== undefined && bytes > limits.maxBytes)
|
|
81
|
+
throw capacityError("bytes");
|
|
82
|
+
if (limits.maxContentCount !== undefined && current.contentCount + candidate.contentCount > limits.maxContentCount)
|
|
83
|
+
throw capacityError("content count");
|
|
84
|
+
}
|
|
85
|
+
async stop(reason = new Error("ContentQueuePipeline stopped")) {
|
|
86
|
+
if (this.stopped)
|
|
87
|
+
return;
|
|
88
|
+
this.stopped = true;
|
|
89
|
+
for (const item of this.work.values()) {
|
|
90
|
+
if (isTerminal(item.state))
|
|
91
|
+
continue;
|
|
92
|
+
item.controller.abort(reason);
|
|
93
|
+
if (item.state !== "preparing") {
|
|
94
|
+
item.state = "cancelled";
|
|
95
|
+
item.error = reason;
|
|
96
|
+
this.observe(item);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.pending.length = 0;
|
|
100
|
+
await this.commitReady();
|
|
101
|
+
await Promise.allSettled([...this.work.values()].map((item) => itemCompletion(item)));
|
|
102
|
+
}
|
|
103
|
+
assertCapacity(candidate) {
|
|
104
|
+
this.assertCapacityAgainst(this.capacity(), candidate);
|
|
105
|
+
}
|
|
106
|
+
drain() {
|
|
107
|
+
while (!this.stopped && this.active < this.options.limits.maxInFlight) {
|
|
108
|
+
const item = this.pending.shift();
|
|
109
|
+
if (!item)
|
|
110
|
+
return;
|
|
111
|
+
this.active += 1;
|
|
112
|
+
item.state = "preparing";
|
|
113
|
+
this.observe(item);
|
|
114
|
+
void this.run(item);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async run(item) {
|
|
118
|
+
try {
|
|
119
|
+
const prepared = await this.options.prepare(item.request, item.controller.signal);
|
|
120
|
+
if (item.controller.signal.aborted || this.stopped)
|
|
121
|
+
throw item.controller.signal.reason ?? new Error("cancelled");
|
|
122
|
+
const actual = normalizeCapacity(this.options.measure(prepared, item.request));
|
|
123
|
+
this.assertMeasuredCapacity(item, actual);
|
|
124
|
+
item.prepared = prepared;
|
|
125
|
+
item.actual = actual;
|
|
126
|
+
item.state = "prepared";
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
item.error = error;
|
|
130
|
+
item.state = item.controller.signal.aborted || this.stopped ? "cancelled" : "terminal-failed";
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
this.active -= 1;
|
|
134
|
+
this.observe(item);
|
|
135
|
+
await this.commitReady();
|
|
136
|
+
this.drain();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async commitReady() {
|
|
140
|
+
if (this.committing)
|
|
141
|
+
return;
|
|
142
|
+
this.committing = true;
|
|
143
|
+
try {
|
|
144
|
+
while (true) {
|
|
145
|
+
const item = [...this.work.values()].find((candidate) => candidate.sequence === this.nextCommitSequence);
|
|
146
|
+
if (!item || !["prepared", "terminal-failed", "cancelled"].includes(item.state))
|
|
147
|
+
return;
|
|
148
|
+
const observation = this.toObservation(item);
|
|
149
|
+
try {
|
|
150
|
+
await this.options.commit(observation, item.prepared, item.request);
|
|
151
|
+
if (item.state === "prepared")
|
|
152
|
+
item.resolve(item.prepared);
|
|
153
|
+
else
|
|
154
|
+
item.reject(item.error);
|
|
155
|
+
this.nextCommitSequence += 1;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
item.error = error;
|
|
159
|
+
item.state = "terminal-failed";
|
|
160
|
+
this.observe(item);
|
|
161
|
+
item.reject(error);
|
|
162
|
+
this.nextCommitSequence += 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
this.committing = false;
|
|
168
|
+
const next = [...this.work.values()].find((candidate) => candidate.sequence === this.nextCommitSequence);
|
|
169
|
+
if (next && ["prepared", "terminal-failed", "cancelled"].includes(next.state))
|
|
170
|
+
void this.commitReady();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
observe(item) {
|
|
174
|
+
this.options.onObservation?.(this.toObservation(item));
|
|
175
|
+
}
|
|
176
|
+
toObservation(item) {
|
|
177
|
+
return { identity: item.identity, state: item.state, sequence: item.sequence, ...(item.error === undefined ? {} : { error: item.error }) };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function capacityOf(current) {
|
|
181
|
+
const visualKeys = new Set();
|
|
182
|
+
return current.reduce((total, item) => {
|
|
183
|
+
const measure = item.actual ?? item.estimate;
|
|
184
|
+
const countVisual = !measure.visualKey || !visualKeys.has(measure.visualKey);
|
|
185
|
+
if (measure.visualKey)
|
|
186
|
+
visualKeys.add(measure.visualKey);
|
|
187
|
+
total.durationMs += measure.durationMs;
|
|
188
|
+
total.bytes += countVisual ? measure.bytes : 0;
|
|
189
|
+
total.contentCount += measure.contentCount;
|
|
190
|
+
return total;
|
|
191
|
+
}, { durationMs: 0, bytes: 0, contentCount: 0 });
|
|
192
|
+
}
|
|
193
|
+
function itemCompletion(item) {
|
|
194
|
+
return item.completion;
|
|
195
|
+
}
|
|
196
|
+
function normalizeIdentity(identity) {
|
|
197
|
+
const result = { ...identity, channelId: identity.channelId.trim(), runEpoch: identity.runEpoch.trim(), contentKey: identity.contentKey.trim() };
|
|
198
|
+
if (!result.channelId || !result.runEpoch || !result.contentKey)
|
|
199
|
+
throw new TypeError("content identity fields are required");
|
|
200
|
+
if (!Number.isInteger(result.ordinal) || result.ordinal < 0)
|
|
201
|
+
throw new TypeError("ordinal must be a non-negative integer");
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
function identityKey(identity) {
|
|
205
|
+
return JSON.stringify([identity.channelId, identity.runEpoch, identity.contentKey, identity.ordinal]);
|
|
206
|
+
}
|
|
207
|
+
function normalizeCapacity(value) {
|
|
208
|
+
for (const [name, amount] of Object.entries({ durationMs: value.durationMs, bytes: value.bytes, contentCount: value.contentCount })) {
|
|
209
|
+
if (!Number.isFinite(amount) || amount < 0)
|
|
210
|
+
throw new TypeError(`${name} must be non-negative`);
|
|
211
|
+
}
|
|
212
|
+
if (!Number.isInteger(value.contentCount))
|
|
213
|
+
throw new TypeError("contentCount must be an integer");
|
|
214
|
+
return { ...value, visualKey: value.visualKey?.trim() || undefined };
|
|
215
|
+
}
|
|
216
|
+
function isTerminal(state) {
|
|
217
|
+
return state === "completed" || state === "terminal-failed" || state === "cancelled";
|
|
218
|
+
}
|
|
219
|
+
function capacityError(kind) {
|
|
220
|
+
const error = new Error(`Content queue ${kind} capacity exceeded`);
|
|
221
|
+
error.name = "ContentQueueCapacityError";
|
|
222
|
+
return error;
|
|
223
|
+
}
|
|
224
|
+
function assertPositiveInteger(name, value) {
|
|
225
|
+
if (!Number.isInteger(value) || value < 1)
|
|
226
|
+
throw new TypeError(`${name} must be a positive integer`);
|
|
227
|
+
}
|
|
228
|
+
function assertOptionalLimit(name, value) {
|
|
229
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0))
|
|
230
|
+
throw new TypeError(`${name} must be non-negative`);
|
|
231
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portalshq/capability-queue-broadcast",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
|
-
"url": "git+https://github.com/portalshq/portals-cloud.git"
|
|
6
|
+
"url": "git+https://github.com/portalshq/portals-cloud.git",
|
|
7
|
+
"directory": "packages/queue-broadcast"
|
|
7
8
|
},
|
|
8
9
|
"description": "Server-only client for isolated Queue Broadcast Server instances.",
|
|
9
10
|
"type": "module",
|
|
@@ -14,7 +15,6 @@
|
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
16
17
|
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
17
|
-
"publish": "node ../../scripts/publish-workspace.mjs",
|
|
18
18
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
19
19
|
"test": "vitest run",
|
|
20
20
|
"test:coverage": "vitest run --coverage",
|
|
@@ -22,13 +22,17 @@
|
|
|
22
22
|
"generate:check": "npm run generate && npm run typecheck"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@portalshq/capability-realtime-fanout": "^0.1.
|
|
26
|
-
"@portalshq/capability-video-delivery": "^0.1.
|
|
25
|
+
"@portalshq/capability-realtime-fanout": "^0.1.8",
|
|
26
|
+
"@portalshq/capability-video-delivery": "^0.1.8"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^20.0.0",
|
|
30
30
|
"@vitest/coverage-v8": "^3.2.7",
|
|
31
31
|
"typescript": "^5.5.0",
|
|
32
32
|
"vitest": "^3.2.7"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
33
37
|
}
|
|
34
38
|
}
|