@fedify/netlify 2.4.0-pr.1020.43 → 2.4.0
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 +72 -25
- package/dist/mod.cjs +118 -5
- package/dist/mod.d.cts +82 -2
- package/dist/mod.d.ts +82 -2
- package/dist/mod.js +112 -1
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- deno-fmt-ignore-file -->
|
|
2
2
|
|
|
3
|
-
@fedify/netlify: Run Fedify
|
|
4
|
-
|
|
3
|
+
@fedify/netlify: Run Fedify with Netlify blobs and async workloads
|
|
4
|
+
==================================================================
|
|
5
5
|
|
|
6
6
|
[![JSR][JSR badge]][JSR]
|
|
7
7
|
[![npm][npm badge]][npm]
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
*This package is available since Fedify 2.4.0.*
|
|
11
11
|
|
|
12
|
-
This package connects [Fedify]'s [`MessageQueue`]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
This package connects [Fedify]'s [`KvStore`] and [`MessageQueue`] APIs to
|
|
13
|
+
Netlify. `NetlifyBlobsKvStore` stores federation state in [Netlify Blobs],
|
|
14
|
+
`NetlifyMessageQueue` publishes durable events through
|
|
15
|
+
[Netlify Async Workloads], and `createNetlifyQueueHandler()` turns a Netlify
|
|
16
|
+
Function into their consumer.
|
|
16
17
|
|
|
17
18
|
The initial release targets Netlify Functions, not Netlify Edge Functions.
|
|
18
19
|
|
|
@@ -23,13 +24,17 @@ The initial release targets Netlify Functions, not Netlify Edge Functions.
|
|
|
23
24
|
[@fedify@hackers.pub badge]: https://fedi-badge.minhee.org/@fedify@hackers.pub/followers.svg
|
|
24
25
|
[@fedify@hackers.pub]: https://hackers.pub/@fedify
|
|
25
26
|
[Fedify]: https://fedify.dev/
|
|
27
|
+
[`KvStore`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore
|
|
26
28
|
[`MessageQueue`]: https://jsr.io/@fedify/fedify/doc/federation/~/MessageQueue
|
|
29
|
+
[Netlify Blobs]: https://docs.netlify.com/build/data-and-storage/netlify-blobs/
|
|
27
30
|
[Netlify Async Workloads]: https://docs.netlify.com/build/async-workloads/get-started/
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
Installation
|
|
31
34
|
------------
|
|
32
35
|
|
|
36
|
+
For queue-only usage, install the package with Netlify Async Workloads:
|
|
37
|
+
|
|
33
38
|
~~~~ sh
|
|
34
39
|
deno add jsr:@fedify/netlify npm:@netlify/async-workloads # Deno
|
|
35
40
|
npm add @fedify/netlify @netlify/async-workloads # npm
|
|
@@ -38,8 +43,62 @@ yarn add @fedify/netlify @netlify/async-workloads # Yarn
|
|
|
38
43
|
bun add @fedify/netlify @netlify/async-workloads # Bun
|
|
39
44
|
~~~~
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
|
|
47
|
+
Usage
|
|
48
|
+
-----
|
|
49
|
+
|
|
50
|
+
To use `NetlifyBlobsKvStore`, also install Netlify Blobs:
|
|
51
|
+
|
|
52
|
+
~~~~ sh
|
|
53
|
+
deno add npm:@netlify/blobs # Deno
|
|
54
|
+
npm add @netlify/blobs # npm
|
|
55
|
+
pnpm add @netlify/blobs # pnpm
|
|
56
|
+
yarn add @netlify/blobs # Yarn
|
|
57
|
+
bun add @netlify/blobs # Bun
|
|
58
|
+
~~~~
|
|
59
|
+
|
|
60
|
+
Create one store and queue for both the web application and the workload
|
|
61
|
+
function. `NetlifyBlobsKvStore` provides the compare-and-set (CAS) support
|
|
62
|
+
required when Fedify emits messages with an `orderingKey`, without requiring a
|
|
63
|
+
separate database:
|
|
64
|
+
|
|
65
|
+
~~~~ typescript
|
|
66
|
+
import { AsyncWorkloadsClient } from "@netlify/async-workloads";
|
|
67
|
+
import { getStore } from "@netlify/blobs";
|
|
68
|
+
import {
|
|
69
|
+
NetlifyBlobsKvStore,
|
|
70
|
+
NetlifyMessageQueue,
|
|
71
|
+
} from "@fedify/netlify";
|
|
72
|
+
|
|
73
|
+
export const kv = new NetlifyBlobsKvStore(getStore({
|
|
74
|
+
name: "fedify",
|
|
75
|
+
consistency: "strong",
|
|
76
|
+
}));
|
|
77
|
+
export const queue = new NetlifyMessageQueue({
|
|
78
|
+
client: new AsyncWorkloadsClient(),
|
|
79
|
+
orderingKv: kv,
|
|
80
|
+
});
|
|
81
|
+
~~~~
|
|
82
|
+
|
|
83
|
+
CAS operations always use strong reads and conditional writes. The
|
|
84
|
+
`consistency: "strong"` setting above also gives ordinary `get()` calls and
|
|
85
|
+
listings strong consistency. You can omit it when lower-latency, eventually
|
|
86
|
+
consistent ordinary reads are acceptable.
|
|
87
|
+
|
|
88
|
+
TTL expiration is logical: expired blobs remain in Netlify Blobs but are
|
|
89
|
+
hidden by `get()` and `list()`. CAS deletion similarly writes a tombstone so
|
|
90
|
+
that a concurrent writer cannot recreate the key undetected. Tombstones are
|
|
91
|
+
also hidden and can be replaced by a later CAS operation, but they are not
|
|
92
|
+
removed automatically. Use a dedicated Netlify Blobs store and account for
|
|
93
|
+
these retained blobs. Only remove them during maintenance when concurrent CAS
|
|
94
|
+
operations for those keys have stopped; physical deletion during a race would
|
|
95
|
+
remove the version token that protects the key.
|
|
96
|
+
|
|
97
|
+
### PostgreSQL alternative
|
|
98
|
+
|
|
99
|
+
If the application already uses PostgreSQL, `PostgresKvStore` can provide the
|
|
100
|
+
same CAS-capable ordering storage. Install `@fedify/postgres`,
|
|
101
|
+
`@netlify/database`, and `postgres` in addition to the base dependencies:
|
|
43
102
|
|
|
44
103
|
~~~~ sh
|
|
45
104
|
deno add jsr:@fedify/postgres npm:@netlify/database npm:postgres # Deno
|
|
@@ -49,29 +108,20 @@ yarn add @fedify/postgres @netlify/database postgres # Yarn
|
|
|
49
108
|
bun add @fedify/postgres @netlify/database postgres # Bun
|
|
50
109
|
~~~~
|
|
51
110
|
|
|
52
|
-
|
|
53
|
-
Usage
|
|
54
|
-
-----
|
|
55
|
-
|
|
56
|
-
Create one queue for both the web application and the workload function. A
|
|
57
|
-
CAS-capable `KvStore`, such as `PostgresKvStore`, is required if Fedify emits
|
|
58
|
-
messages with an `orderingKey`:
|
|
111
|
+
Then replace the `kv` declaration from the main example:
|
|
59
112
|
|
|
60
113
|
~~~~ typescript
|
|
61
|
-
import { AsyncWorkloadsClient } from "@netlify/async-workloads";
|
|
62
114
|
import { getConnectionString } from "@netlify/database";
|
|
63
|
-
import { NetlifyMessageQueue } from "@fedify/netlify";
|
|
64
115
|
import { PostgresKvStore } from "@fedify/postgres";
|
|
65
116
|
import postgres from "postgres";
|
|
66
117
|
|
|
67
118
|
const sql = postgres(getConnectionString());
|
|
68
119
|
export const kv = new PostgresKvStore(sql);
|
|
69
|
-
export const queue = new NetlifyMessageQueue({
|
|
70
|
-
client: new AsyncWorkloadsClient(),
|
|
71
|
-
orderingKv: kv,
|
|
72
|
-
});
|
|
73
120
|
~~~~
|
|
74
121
|
|
|
122
|
+
Ordering state must use crash-safe storage. `PostgresKvStore` creates a logged
|
|
123
|
+
table by default; do not pass `unlogged: true` for `orderingKv`.
|
|
124
|
+
|
|
75
125
|
Pass the queue to Fedify with `manuallyStartQueue: true`. Async Workloads
|
|
76
126
|
invokes the consumer, so `NetlifyMessageQueue.listen()` is intentionally not
|
|
77
127
|
available:
|
|
@@ -142,9 +192,6 @@ because a lost response does not prove that the router rejected the event.
|
|
|
142
192
|
Use the error's `orderingKey` and `orderingSequence` for manual recovery only
|
|
143
193
|
after ruling out delivery.
|
|
144
194
|
|
|
145
|
-
Ordering state must use crash-safe storage. `PostgresKvStore` creates a logged
|
|
146
|
-
table by default; do not pass `unlogged: true` for `orderingKv`.
|
|
147
|
-
|
|
148
195
|
Netlify limits an event payload to 500 KB. Fedify messages, including any
|
|
149
196
|
embedded activity, must remain below that limit.
|
|
150
197
|
|
|
@@ -156,7 +203,7 @@ Fedify rejects a multi-message `enqueueTaskMany()` call with one
|
|
|
156
203
|
batch enqueue so a failed partial send cannot produce duplicates on retry.
|
|
157
204
|
|
|
158
205
|
See the [deployment manual] and the [Netlify Astro example] for a complete
|
|
159
|
-
setup with Netlify Database.
|
|
206
|
+
setup using the PostgreSQL alternative with Netlify Database.
|
|
160
207
|
|
|
161
208
|
[deployment manual]: https://fedify.dev/manual/deploy#netlify-functions
|
|
162
209
|
[Netlify Astro example]: https://github.com/fedify-dev/fedify/tree/main/examples/netlify-astro
|
package/dist/mod.cjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
let _netlify_async_workloads = require("@netlify/async-workloads");
|
|
4
|
+
let byte_encodings_base64url = require("byte-encodings/base64url");
|
|
5
|
+
let es_toolkit = require("es-toolkit");
|
|
4
6
|
//#region ../../node_modules/.pnpm/temporal-polyfill@1.0.1/node_modules/temporal-polyfill/chunks/root.js
|
|
5
7
|
const NativeTemporal = globalThis.Temporal;
|
|
6
8
|
//#endregion
|
|
@@ -3546,7 +3548,7 @@ const Now = /*@__PURE__*/ Object.defineProperties({}, {
|
|
|
3546
3548
|
}
|
|
3547
3549
|
})
|
|
3548
3550
|
});
|
|
3549
|
-
const Temporal$
|
|
3551
|
+
const Temporal$3 = /*@__PURE__*/ Object.defineProperties({}, {
|
|
3550
3552
|
...createStringTagDescriptors("Temporal"),
|
|
3551
3553
|
...createPropDescriptors({
|
|
3552
3554
|
PlainYearMonth,
|
|
@@ -3562,13 +3564,13 @@ const Temporal$2 = /*@__PURE__*/ Object.defineProperties({}, {
|
|
|
3562
3564
|
});
|
|
3563
3565
|
//#endregion
|
|
3564
3566
|
//#region ../../node_modules/.pnpm/temporal-polyfill@1.0.1/node_modules/temporal-polyfill/index.js
|
|
3565
|
-
const Temporal$
|
|
3567
|
+
const Temporal$2 = NativeTemporal || Temporal$3;
|
|
3566
3568
|
NativeTemporal && Date.prototype.toTemporalInstant;
|
|
3567
3569
|
//#endregion
|
|
3568
3570
|
//#region src/mq.ts
|
|
3569
|
-
const Temporal = Temporal$
|
|
3571
|
+
const Temporal$1 = Temporal$2;
|
|
3570
3572
|
const defaultEventName = "fedify:queue";
|
|
3571
|
-
const defaultOrderingRetryDelay = Temporal.Duration.from({ seconds: 5 });
|
|
3573
|
+
const defaultOrderingRetryDelay = Temporal$1.Duration.from({ seconds: 5 });
|
|
3572
3574
|
/**
|
|
3573
3575
|
* An error raised when an event send is not acknowledged.
|
|
3574
3576
|
*
|
|
@@ -3597,7 +3599,7 @@ var NetlifyMessageQueueSendError = class extends Error {
|
|
|
3597
3599
|
};
|
|
3598
3600
|
const orderingOptions = /* @__PURE__ */ new WeakMap();
|
|
3599
3601
|
function duration(value, defaultValue, name) {
|
|
3600
|
-
const result = value == null ? defaultValue : Temporal.Duration.from(value);
|
|
3602
|
+
const result = value == null ? defaultValue : Temporal$1.Duration.from(value);
|
|
3601
3603
|
const milliseconds = result.total("milliseconds");
|
|
3602
3604
|
if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new RangeError(`${name} must be a positive finite duration.`);
|
|
3603
3605
|
return result;
|
|
@@ -3884,6 +3886,117 @@ function createNetlifyQueueHandler(options) {
|
|
|
3884
3886
|
return createNetlifyQueueHandlerWith(_netlify_async_workloads.asyncWorkloadFn, options);
|
|
3885
3887
|
}
|
|
3886
3888
|
//#endregion
|
|
3889
|
+
//#region src/kv.ts
|
|
3890
|
+
const Temporal = Temporal$2;
|
|
3891
|
+
const MAX_BLOB_KEY_BYTES = 600;
|
|
3892
|
+
const BLOB_KEY_PREFIX = "fedify1.";
|
|
3893
|
+
const BLOB_KEY_SEPARATOR = ".";
|
|
3894
|
+
const textDecoder = new TextDecoder();
|
|
3895
|
+
/**
|
|
3896
|
+
* A key-value store that uses Netlify Blobs.
|
|
3897
|
+
*
|
|
3898
|
+
* @since 2.4.0
|
|
3899
|
+
*/
|
|
3900
|
+
var NetlifyBlobsKvStore = class {
|
|
3901
|
+
#store;
|
|
3902
|
+
/**
|
|
3903
|
+
* Creates a new Netlify Blobs-backed key-value store.
|
|
3904
|
+
* @param store The Netlify Blobs store to use.
|
|
3905
|
+
*/
|
|
3906
|
+
constructor(store) {
|
|
3907
|
+
this.#store = store;
|
|
3908
|
+
}
|
|
3909
|
+
serializeKey(key) {
|
|
3910
|
+
const serialized = BLOB_KEY_PREFIX + key.map((part) => (0, byte_encodings_base64url.encodeBase64Url)(part)).join(BLOB_KEY_SEPARATOR) + BLOB_KEY_SEPARATOR;
|
|
3911
|
+
if (new TextEncoder().encode(serialized).byteLength > MAX_BLOB_KEY_BYTES) throw new RangeError("The encoded key exceeds Netlify Blobs' 600-byte key limit.");
|
|
3912
|
+
return serialized;
|
|
3913
|
+
}
|
|
3914
|
+
deserializeKey(key) {
|
|
3915
|
+
return key.slice(8, -1).split(BLOB_KEY_SEPARATOR).map((part) => textDecoder.decode((0, byte_encodings_base64url.decodeBase64Url)(part)));
|
|
3916
|
+
}
|
|
3917
|
+
serializePrefix(prefix) {
|
|
3918
|
+
return prefix == null ? BLOB_KEY_PREFIX : this.serializeKey(prefix);
|
|
3919
|
+
}
|
|
3920
|
+
isAbsent(metadata) {
|
|
3921
|
+
if (metadata.tombstone === true) return true;
|
|
3922
|
+
const expireIn = metadata.expireIn;
|
|
3923
|
+
return typeof expireIn === "number" && expireIn <= Temporal.Now.instant().epochMilliseconds;
|
|
3924
|
+
}
|
|
3925
|
+
getEtag(entry) {
|
|
3926
|
+
if (entry?.etag == null) throw new Error("Netlify Blobs did not return an ETag.");
|
|
3927
|
+
return entry.etag;
|
|
3928
|
+
}
|
|
3929
|
+
/**
|
|
3930
|
+
* {@inheritDoc KvStore.get}
|
|
3931
|
+
*/
|
|
3932
|
+
async get(key) {
|
|
3933
|
+
const result = await this.#store.getWithMetadata(this.serializeKey(key), { type: "json" });
|
|
3934
|
+
if (result === null || this.isAbsent(result.metadata)) return void 0;
|
|
3935
|
+
return result.data;
|
|
3936
|
+
}
|
|
3937
|
+
/**
|
|
3938
|
+
* {@inheritDoc KvStore.set}
|
|
3939
|
+
*/
|
|
3940
|
+
async set(key, value, options) {
|
|
3941
|
+
const expireIn = options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond");
|
|
3942
|
+
await this.#store.setJSON(this.serializeKey(key), value, { metadata: { "expireIn": expireIn } });
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* {@inheritDoc KvStore.delete}
|
|
3946
|
+
*/
|
|
3947
|
+
delete(key) {
|
|
3948
|
+
return this.#store.delete(this.serializeKey(key));
|
|
3949
|
+
}
|
|
3950
|
+
/**
|
|
3951
|
+
* {@inheritDoc KvStore.list}
|
|
3952
|
+
*/
|
|
3953
|
+
async *list(prefix) {
|
|
3954
|
+
const pages = this.#store.list({
|
|
3955
|
+
paginate: true,
|
|
3956
|
+
prefix: this.serializePrefix(prefix)
|
|
3957
|
+
});
|
|
3958
|
+
for await (const page of pages) for (const blob of page.blobs) {
|
|
3959
|
+
const key = this.deserializeKey(blob.key);
|
|
3960
|
+
const value = await this.get(key);
|
|
3961
|
+
if (value === void 0) continue;
|
|
3962
|
+
yield {
|
|
3963
|
+
key,
|
|
3964
|
+
value
|
|
3965
|
+
};
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* {@inheritDoc KvStore.cas}
|
|
3970
|
+
*/
|
|
3971
|
+
async cas(key, expectedValue, newValue, options) {
|
|
3972
|
+
while (true) {
|
|
3973
|
+
const entry = await this.#store.getWithMetadata(this.serializeKey(key), {
|
|
3974
|
+
consistency: "strong",
|
|
3975
|
+
type: "json"
|
|
3976
|
+
});
|
|
3977
|
+
if (!(0, es_toolkit.isEqual)(entry === null || this.isAbsent(entry.metadata) ? void 0 : entry.data, expectedValue)) return false;
|
|
3978
|
+
if (entry === null && newValue === void 0) return true;
|
|
3979
|
+
const serializedKey = this.serializeKey(key);
|
|
3980
|
+
if (newValue === void 0) {
|
|
3981
|
+
if ((await this.#store.setJSON(serializedKey, null, {
|
|
3982
|
+
onlyIfMatch: this.getEtag(entry),
|
|
3983
|
+
metadata: { tombstone: true }
|
|
3984
|
+
})).modified) return true;
|
|
3985
|
+
continue;
|
|
3986
|
+
}
|
|
3987
|
+
const metadata = { "expireIn": options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond") };
|
|
3988
|
+
if ((entry === null ? await this.#store.setJSON(serializedKey, newValue, {
|
|
3989
|
+
onlyIfNew: true,
|
|
3990
|
+
metadata
|
|
3991
|
+
}) : await this.#store.setJSON(serializedKey, newValue, {
|
|
3992
|
+
onlyIfMatch: this.getEtag(entry),
|
|
3993
|
+
metadata
|
|
3994
|
+
})).modified) return true;
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
};
|
|
3998
|
+
//#endregion
|
|
3999
|
+
exports.NetlifyBlobsKvStore = NetlifyBlobsKvStore;
|
|
3887
4000
|
exports.NetlifyMessageQueue = NetlifyMessageQueue;
|
|
3888
4001
|
exports.NetlifyMessageQueueSendError = NetlifyMessageQueueSendError;
|
|
3889
4002
|
exports.createNetlifyQueueHandler = createNetlifyQueueHandler;
|
package/dist/mod.d.cts
CHANGED
|
@@ -1,9 +1,49 @@
|
|
|
1
1
|
/// <reference lib="esnext.temporal" />
|
|
2
|
-
import { Federation, KvStore, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
|
|
2
|
+
import { Federation, KvKey, KvStore, KvStoreListEntry, KvStoreSetOptions, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
|
|
3
3
|
import { AsyncWorkloadEvent, CustomAsyncWorkloadEvent, asyncWorkloadFn } from "@netlify/async-workloads";
|
|
4
4
|
|
|
5
5
|
//#region src/types.d.ts
|
|
6
6
|
/**
|
|
7
|
+
* The subset of Netlify's Blobs `Store` used by this package.
|
|
8
|
+
*
|
|
9
|
+
* The real Netlify Blobs `Store` satisfies this interface. The narrower
|
|
10
|
+
* interface also keeps Netlify Blobs optional and makes it possible to supply
|
|
11
|
+
* a test double.
|
|
12
|
+
*
|
|
13
|
+
* @since 2.4.0
|
|
14
|
+
*/
|
|
15
|
+
interface NetlifyBlobsStore {
|
|
16
|
+
getWithMetadata(key: string, options: {
|
|
17
|
+
readonly consistency?: "eventual" | "strong";
|
|
18
|
+
readonly type: "json";
|
|
19
|
+
}): Promise<{
|
|
20
|
+
readonly data: unknown;
|
|
21
|
+
readonly etag?: string;
|
|
22
|
+
readonly metadata: Record<string, unknown>;
|
|
23
|
+
} | null>;
|
|
24
|
+
setJSON(key: string, data: unknown, options?: {
|
|
25
|
+
readonly metadata?: Record<string, unknown>;
|
|
26
|
+
} & ({
|
|
27
|
+
readonly onlyIfNew?: boolean;
|
|
28
|
+
readonly onlyIfMatch?: never;
|
|
29
|
+
} | {
|
|
30
|
+
readonly onlyIfMatch?: string;
|
|
31
|
+
readonly onlyIfNew?: never;
|
|
32
|
+
})): Promise<{
|
|
33
|
+
readonly etag?: string;
|
|
34
|
+
readonly modified: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
delete(key: string): Promise<void>;
|
|
37
|
+
list(options: {
|
|
38
|
+
readonly paginate: true;
|
|
39
|
+
readonly prefix?: string;
|
|
40
|
+
}): AsyncIterable<{
|
|
41
|
+
readonly blobs: readonly {
|
|
42
|
+
readonly key: string;
|
|
43
|
+
}[];
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
7
47
|
* The subset of Netlify's `AsyncWorkloadsClient` used by this package.
|
|
8
48
|
*
|
|
9
49
|
* The real `AsyncWorkloadsClient` satisfies this interface. The narrower
|
|
@@ -176,4 +216,44 @@ type WorkloadFunction = ReturnType<typeof asyncWorkloadFn<NetlifyQueueEvent>>;
|
|
|
176
216
|
*/
|
|
177
217
|
declare function createNetlifyQueueHandler<TContextData>(options: NetlifyQueueHandlerOptions<TContextData>): WorkloadFunction;
|
|
178
218
|
//#endregion
|
|
179
|
-
|
|
219
|
+
//#region src/kv.d.ts
|
|
220
|
+
/**
|
|
221
|
+
* A key-value store that uses Netlify Blobs.
|
|
222
|
+
*
|
|
223
|
+
* @since 2.4.0
|
|
224
|
+
*/
|
|
225
|
+
declare class NetlifyBlobsKvStore implements KvStore {
|
|
226
|
+
#private;
|
|
227
|
+
/**
|
|
228
|
+
* Creates a new Netlify Blobs-backed key-value store.
|
|
229
|
+
* @param store The Netlify Blobs store to use.
|
|
230
|
+
*/
|
|
231
|
+
constructor(store: NetlifyBlobsStore);
|
|
232
|
+
private serializeKey;
|
|
233
|
+
private deserializeKey;
|
|
234
|
+
private serializePrefix;
|
|
235
|
+
private isAbsent;
|
|
236
|
+
private getEtag;
|
|
237
|
+
/**
|
|
238
|
+
* {@inheritDoc KvStore.get}
|
|
239
|
+
*/
|
|
240
|
+
get<T = unknown>(key: KvKey): Promise<T | undefined>;
|
|
241
|
+
/**
|
|
242
|
+
* {@inheritDoc KvStore.set}
|
|
243
|
+
*/
|
|
244
|
+
set(key: KvKey, value: unknown, options?: KvStoreSetOptions | undefined): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* {@inheritDoc KvStore.delete}
|
|
247
|
+
*/
|
|
248
|
+
delete(key: KvKey): Promise<void>;
|
|
249
|
+
/**
|
|
250
|
+
* {@inheritDoc KvStore.list}
|
|
251
|
+
*/
|
|
252
|
+
list(prefix?: KvKey): AsyncIterable<KvStoreListEntry>;
|
|
253
|
+
/**
|
|
254
|
+
* {@inheritDoc KvStore.cas}
|
|
255
|
+
*/
|
|
256
|
+
cas(key: KvKey, expectedValue: unknown, newValue: unknown, options?: KvStoreSetOptions): Promise<boolean>;
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
export { type NetlifyAsyncWorkloadsClient, NetlifyBlobsKvStore, type NetlifyBlobsStore, NetlifyMessageQueue, type NetlifyMessageQueueOptions, NetlifyMessageQueueSendError, type NetlifyQueueEvent, type NetlifyQueueEventData, type NetlifyQueueHandlerOptions, createNetlifyQueueHandler };
|
package/dist/mod.d.ts
CHANGED
|
@@ -1,9 +1,49 @@
|
|
|
1
1
|
/// <reference lib="esnext.temporal" />
|
|
2
2
|
import { AsyncWorkloadEvent, CustomAsyncWorkloadEvent, asyncWorkloadFn } from "@netlify/async-workloads";
|
|
3
|
-
import { Federation, KvStore, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
|
|
3
|
+
import { Federation, KvKey, KvStore, KvStoreListEntry, KvStoreSetOptions, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
|
|
4
4
|
|
|
5
5
|
//#region src/types.d.ts
|
|
6
6
|
/**
|
|
7
|
+
* The subset of Netlify's Blobs `Store` used by this package.
|
|
8
|
+
*
|
|
9
|
+
* The real Netlify Blobs `Store` satisfies this interface. The narrower
|
|
10
|
+
* interface also keeps Netlify Blobs optional and makes it possible to supply
|
|
11
|
+
* a test double.
|
|
12
|
+
*
|
|
13
|
+
* @since 2.4.0
|
|
14
|
+
*/
|
|
15
|
+
interface NetlifyBlobsStore {
|
|
16
|
+
getWithMetadata(key: string, options: {
|
|
17
|
+
readonly consistency?: "eventual" | "strong";
|
|
18
|
+
readonly type: "json";
|
|
19
|
+
}): Promise<{
|
|
20
|
+
readonly data: unknown;
|
|
21
|
+
readonly etag?: string;
|
|
22
|
+
readonly metadata: Record<string, unknown>;
|
|
23
|
+
} | null>;
|
|
24
|
+
setJSON(key: string, data: unknown, options?: {
|
|
25
|
+
readonly metadata?: Record<string, unknown>;
|
|
26
|
+
} & ({
|
|
27
|
+
readonly onlyIfNew?: boolean;
|
|
28
|
+
readonly onlyIfMatch?: never;
|
|
29
|
+
} | {
|
|
30
|
+
readonly onlyIfMatch?: string;
|
|
31
|
+
readonly onlyIfNew?: never;
|
|
32
|
+
})): Promise<{
|
|
33
|
+
readonly etag?: string;
|
|
34
|
+
readonly modified: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
delete(key: string): Promise<void>;
|
|
37
|
+
list(options: {
|
|
38
|
+
readonly paginate: true;
|
|
39
|
+
readonly prefix?: string;
|
|
40
|
+
}): AsyncIterable<{
|
|
41
|
+
readonly blobs: readonly {
|
|
42
|
+
readonly key: string;
|
|
43
|
+
}[];
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
7
47
|
* The subset of Netlify's `AsyncWorkloadsClient` used by this package.
|
|
8
48
|
*
|
|
9
49
|
* The real `AsyncWorkloadsClient` satisfies this interface. The narrower
|
|
@@ -176,4 +216,44 @@ type WorkloadFunction = ReturnType<typeof asyncWorkloadFn<NetlifyQueueEvent>>;
|
|
|
176
216
|
*/
|
|
177
217
|
declare function createNetlifyQueueHandler<TContextData>(options: NetlifyQueueHandlerOptions<TContextData>): WorkloadFunction;
|
|
178
218
|
//#endregion
|
|
179
|
-
|
|
219
|
+
//#region src/kv.d.ts
|
|
220
|
+
/**
|
|
221
|
+
* A key-value store that uses Netlify Blobs.
|
|
222
|
+
*
|
|
223
|
+
* @since 2.4.0
|
|
224
|
+
*/
|
|
225
|
+
declare class NetlifyBlobsKvStore implements KvStore {
|
|
226
|
+
#private;
|
|
227
|
+
/**
|
|
228
|
+
* Creates a new Netlify Blobs-backed key-value store.
|
|
229
|
+
* @param store The Netlify Blobs store to use.
|
|
230
|
+
*/
|
|
231
|
+
constructor(store: NetlifyBlobsStore);
|
|
232
|
+
private serializeKey;
|
|
233
|
+
private deserializeKey;
|
|
234
|
+
private serializePrefix;
|
|
235
|
+
private isAbsent;
|
|
236
|
+
private getEtag;
|
|
237
|
+
/**
|
|
238
|
+
* {@inheritDoc KvStore.get}
|
|
239
|
+
*/
|
|
240
|
+
get<T = unknown>(key: KvKey): Promise<T | undefined>;
|
|
241
|
+
/**
|
|
242
|
+
* {@inheritDoc KvStore.set}
|
|
243
|
+
*/
|
|
244
|
+
set(key: KvKey, value: unknown, options?: KvStoreSetOptions | undefined): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* {@inheritDoc KvStore.delete}
|
|
247
|
+
*/
|
|
248
|
+
delete(key: KvKey): Promise<void>;
|
|
249
|
+
/**
|
|
250
|
+
* {@inheritDoc KvStore.list}
|
|
251
|
+
*/
|
|
252
|
+
list(prefix?: KvKey): AsyncIterable<KvStoreListEntry>;
|
|
253
|
+
/**
|
|
254
|
+
* {@inheritDoc KvStore.cas}
|
|
255
|
+
*/
|
|
256
|
+
cas(key: KvKey, expectedValue: unknown, newValue: unknown, options?: KvStoreSetOptions): Promise<boolean>;
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
export { type NetlifyAsyncWorkloadsClient, NetlifyBlobsKvStore, type NetlifyBlobsStore, NetlifyMessageQueue, type NetlifyMessageQueueOptions, NetlifyMessageQueueSendError, type NetlifyQueueEvent, type NetlifyQueueEventData, type NetlifyQueueHandlerOptions, createNetlifyQueueHandler };
|
package/dist/mod.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Temporal } from "temporal-polyfill";
|
|
2
2
|
import { ErrorDoNotRetry, asyncWorkloadFn } from "@netlify/async-workloads";
|
|
3
|
+
import { decodeBase64Url, encodeBase64Url } from "byte-encodings/base64url";
|
|
4
|
+
import { isEqual } from "es-toolkit";
|
|
3
5
|
//#region src/mq.ts
|
|
4
6
|
const defaultEventName = "fedify:queue";
|
|
5
7
|
const defaultOrderingRetryDelay = Temporal.Duration.from({ seconds: 5 });
|
|
@@ -318,4 +320,113 @@ function createNetlifyQueueHandler(options) {
|
|
|
318
320
|
return createNetlifyQueueHandlerWith(asyncWorkloadFn, options);
|
|
319
321
|
}
|
|
320
322
|
//#endregion
|
|
321
|
-
|
|
323
|
+
//#region src/kv.ts
|
|
324
|
+
const MAX_BLOB_KEY_BYTES = 600;
|
|
325
|
+
const BLOB_KEY_PREFIX = "fedify1.";
|
|
326
|
+
const BLOB_KEY_SEPARATOR = ".";
|
|
327
|
+
const textDecoder = new TextDecoder();
|
|
328
|
+
/**
|
|
329
|
+
* A key-value store that uses Netlify Blobs.
|
|
330
|
+
*
|
|
331
|
+
* @since 2.4.0
|
|
332
|
+
*/
|
|
333
|
+
var NetlifyBlobsKvStore = class {
|
|
334
|
+
#store;
|
|
335
|
+
/**
|
|
336
|
+
* Creates a new Netlify Blobs-backed key-value store.
|
|
337
|
+
* @param store The Netlify Blobs store to use.
|
|
338
|
+
*/
|
|
339
|
+
constructor(store) {
|
|
340
|
+
this.#store = store;
|
|
341
|
+
}
|
|
342
|
+
serializeKey(key) {
|
|
343
|
+
const serialized = BLOB_KEY_PREFIX + key.map((part) => encodeBase64Url(part)).join(BLOB_KEY_SEPARATOR) + BLOB_KEY_SEPARATOR;
|
|
344
|
+
if (new TextEncoder().encode(serialized).byteLength > MAX_BLOB_KEY_BYTES) throw new RangeError("The encoded key exceeds Netlify Blobs' 600-byte key limit.");
|
|
345
|
+
return serialized;
|
|
346
|
+
}
|
|
347
|
+
deserializeKey(key) {
|
|
348
|
+
return key.slice(8, -1).split(BLOB_KEY_SEPARATOR).map((part) => textDecoder.decode(decodeBase64Url(part)));
|
|
349
|
+
}
|
|
350
|
+
serializePrefix(prefix) {
|
|
351
|
+
return prefix == null ? BLOB_KEY_PREFIX : this.serializeKey(prefix);
|
|
352
|
+
}
|
|
353
|
+
isAbsent(metadata) {
|
|
354
|
+
if (metadata.tombstone === true) return true;
|
|
355
|
+
const expireIn = metadata.expireIn;
|
|
356
|
+
return typeof expireIn === "number" && expireIn <= Temporal.Now.instant().epochMilliseconds;
|
|
357
|
+
}
|
|
358
|
+
getEtag(entry) {
|
|
359
|
+
if (entry?.etag == null) throw new Error("Netlify Blobs did not return an ETag.");
|
|
360
|
+
return entry.etag;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* {@inheritDoc KvStore.get}
|
|
364
|
+
*/
|
|
365
|
+
async get(key) {
|
|
366
|
+
const result = await this.#store.getWithMetadata(this.serializeKey(key), { type: "json" });
|
|
367
|
+
if (result === null || this.isAbsent(result.metadata)) return void 0;
|
|
368
|
+
return result.data;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* {@inheritDoc KvStore.set}
|
|
372
|
+
*/
|
|
373
|
+
async set(key, value, options) {
|
|
374
|
+
const expireIn = options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond");
|
|
375
|
+
await this.#store.setJSON(this.serializeKey(key), value, { metadata: { "expireIn": expireIn } });
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* {@inheritDoc KvStore.delete}
|
|
379
|
+
*/
|
|
380
|
+
delete(key) {
|
|
381
|
+
return this.#store.delete(this.serializeKey(key));
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* {@inheritDoc KvStore.list}
|
|
385
|
+
*/
|
|
386
|
+
async *list(prefix) {
|
|
387
|
+
const pages = this.#store.list({
|
|
388
|
+
paginate: true,
|
|
389
|
+
prefix: this.serializePrefix(prefix)
|
|
390
|
+
});
|
|
391
|
+
for await (const page of pages) for (const blob of page.blobs) {
|
|
392
|
+
const key = this.deserializeKey(blob.key);
|
|
393
|
+
const value = await this.get(key);
|
|
394
|
+
if (value === void 0) continue;
|
|
395
|
+
yield {
|
|
396
|
+
key,
|
|
397
|
+
value
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* {@inheritDoc KvStore.cas}
|
|
403
|
+
*/
|
|
404
|
+
async cas(key, expectedValue, newValue, options) {
|
|
405
|
+
while (true) {
|
|
406
|
+
const entry = await this.#store.getWithMetadata(this.serializeKey(key), {
|
|
407
|
+
consistency: "strong",
|
|
408
|
+
type: "json"
|
|
409
|
+
});
|
|
410
|
+
if (!isEqual(entry === null || this.isAbsent(entry.metadata) ? void 0 : entry.data, expectedValue)) return false;
|
|
411
|
+
if (entry === null && newValue === void 0) return true;
|
|
412
|
+
const serializedKey = this.serializeKey(key);
|
|
413
|
+
if (newValue === void 0) {
|
|
414
|
+
if ((await this.#store.setJSON(serializedKey, null, {
|
|
415
|
+
onlyIfMatch: this.getEtag(entry),
|
|
416
|
+
metadata: { tombstone: true }
|
|
417
|
+
})).modified) return true;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const metadata = { "expireIn": options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond") };
|
|
421
|
+
if ((entry === null ? await this.#store.setJSON(serializedKey, newValue, {
|
|
422
|
+
onlyIfNew: true,
|
|
423
|
+
metadata
|
|
424
|
+
}) : await this.#store.setJSON(serializedKey, newValue, {
|
|
425
|
+
onlyIfMatch: this.getEtag(entry),
|
|
426
|
+
metadata
|
|
427
|
+
})).modified) return true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
//#endregion
|
|
432
|
+
export { NetlifyBlobsKvStore, NetlifyMessageQueue, NetlifyMessageQueueSendError, createNetlifyQueueHandler };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/netlify",
|
|
3
|
-
"version": "2.4.0
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Integrate Fedify with Netlify Async Workloads",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Fedify",
|
|
@@ -47,22 +47,22 @@
|
|
|
47
47
|
"package.json"
|
|
48
48
|
],
|
|
49
49
|
"dependencies": {
|
|
50
|
+
"byte-encodings": "^1.0.11",
|
|
51
|
+
"es-toolkit": "1.46.1",
|
|
50
52
|
"temporal-polyfill": "^1.0.1"
|
|
51
53
|
},
|
|
52
54
|
"peerDependencies": {
|
|
53
55
|
"@netlify/async-workloads": "^0.0.106",
|
|
54
|
-
"@fedify/fedify": "^2.4.0
|
|
56
|
+
"@fedify/fedify": "^2.4.0"
|
|
55
57
|
},
|
|
56
58
|
"devDependencies": {
|
|
57
59
|
"@js-temporal/polyfill": "^0.5.1",
|
|
58
|
-
"@netlify/database": "^1.1.0",
|
|
59
60
|
"@standard-schema/spec": "^1.1.0",
|
|
60
|
-
"@
|
|
61
|
-
"
|
|
62
|
-
"
|
|
61
|
+
"@netlify/blobs": "11.0.3",
|
|
62
|
+
"@types/node": "^24.12.2",
|
|
63
|
+
"netlify-cli": "^27.4.2",
|
|
63
64
|
"tsdown": "^0.22.0",
|
|
64
|
-
"typescript": "^6.0.0"
|
|
65
|
-
"@fedify/postgres": "^2.4.0-pr.1020.43+41cebe9c"
|
|
65
|
+
"typescript": "^6.0.0"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build:self": "tsdown",
|