Add comprehensive installation and setup documentation

- Add GETTING_STARTED.md with quick start guide and development modes
- Add INSTALL.sh automated installation script
- Add INSTALLATION_CHECKLIST.md, INSTALLATION_SUCCESS.md, and INSTALLATION_SUMMARY.md
- Add QUICK_REFERENCE.md for common commands
- Add SETUP_GUIDE.md with detailed setup instructions
- Update README.md with improved project overview
- Add did-wallet app dependencies and node_modules
This commit is contained in:
Dorian
2026-01-27 17:18:21 +00:00
parent a81f655133
commit 0d073fa89e
22658 changed files with 4494151 additions and 6 deletions
+90
View File
@@ -0,0 +1,90 @@
import type { DidCreateParams, DidMessageResult, DidResolveParams, ResponseStatus, Web5Agent } from '@web5/agent';
import { DidInterface } from '@web5/agent';
/**
* Parameters for creating a DID, specifying the method, options for the DID method, and whether to
* store the DID.
*
* @typeParam method - The DID method to use for creating the DID.
* @typeParam options - Method-specific options for creating the DID.
* @typeParam store - Indicates whether the newly created DID should be stored.
*/
export type DidCreateRequest = Pick<DidCreateParams, 'method' | 'options' | 'store'>;
/**
* The response from a DID creation request, including the operation's status and, if successful,
* the created DID.
*/
export type DidCreateResponse = ResponseStatus & {
/** The result of the DID creation operation, containing the newly created DID, if successful */
did?: DidMessageResult[DidInterface.Create];
};
/**
* The response from resolving a DID, containing the DID Resolution Result.
*
* This type directly maps to the result of a DID resolution operation, providing detailed
* information about the DID document, including any DID document metadata and DID resolution
* metadata,
*/
export type DidResolveResponse = DidMessageResult[DidInterface.Resolve];
/**
* The DID API is used to resolve DIDs.
*
* @beta
*/
export class DidApi {
/**
* Holds the instance of a {@link Web5Agent} that represents the current execution context for
* the `DidApi`. This agent is used to process DID requests.
*/
private agent: Web5Agent;
/** The DID of the tenant under which DID operations are being performed. */
private connectedDid: string;
constructor(options: { agent: Web5Agent, connectedDid: string }) {
this.agent = options.agent;
this.connectedDid = options.connectedDid;
}
/**
* Initiates the creation of a Decentralized Identifier (DID) using the specified method, options,
* and storage preference.
*
* This method sends a request to the Web5 Agent to create a new DID based on the provided method,
* with method-specific options. It also specifies whether the newly created DID should be stored.
*
* @param request - The request parameters for creating a DID, including the method, options, and
* storage flag.
* @returns A promise that resolves to a `DidCreateResponse`, which includes the operation's
* status and, if successful, the newly created DID.
*/
public async create(request: DidCreateRequest): Promise<DidCreateResponse> {
const { result, ...status } = await this.agent.processDidRequest({
messageType : DidInterface.Create,
messageParams : { ...request }
});
return { did: result, ...status };
}
/**
* Resolves a DID to a DID Resolution Result.
*
* @param didUri - The DID or DID URL to resolve.
* @returns A promise that resolves to the DID Resolution Result.
*/
public async resolve(
didUri: DidResolveParams['didUri'], options?: DidResolveParams['options']
): Promise<DidResolveResponse> {
const { result: didResolutionResult } = await this.agent.processDidRequest({
messageParams : { didUri, options },
messageType : DidInterface.Resolve
});
return didResolutionResult;
}
}
+530
View File
@@ -0,0 +1,530 @@
import type {
Web5Agent,
DwnMessage,
DwnResponse,
DwnMessageParams,
DwnResponseStatus,
ProcessDwnRequest,
DwnPaginationCursor,
} from '@web5/agent';
import { isEmptyObject } from '@web5/common';
import { DwnInterface, getRecordAuthor } from '@web5/agent';
import { Record } from './record.js';
import { dataToBlob } from './utils.js';
import { Protocol } from './protocol.js';
/**
* Represents the request payload for configuring a protocol on a Decentralized Web Node (DWN).
*
* This request type is used to specify the configuration options for the protocol.
*/
export type ProtocolsConfigureRequest = {
/** Configuration options for the protocol. */
message: Omit<DwnMessageParams[DwnInterface.ProtocolsConfigure], 'signer'>;
}
/**
* Encapsulates the response from a protocol configuration request to a Decentralized Web Node (DWN).
*
* This response type combines the general operation status with the details of the protocol that
* was configured, if the operation was successful.
*
* @beta
*/
export type ProtocolsConfigureResponse = DwnResponseStatus & {
/** The configured protocol, if successful. */
protocol?: Protocol;
}
/**
* Defines the request structure for querying protocols from a Decentralized Web Node (DWN).
*
* This request type is used to specify the target DWN from which protocols should be queried and
* any additional query filters or options. If the `from` property is not provided, the query will
* target the local DWN. If the `from` property is provided, the query will target the specified
* remote DWN.
*/
export type ProtocolsQueryRequest = {
/** Optional DID specifying the remote target DWN tenant to be queried. */
from?: string;
/** Query filters and options that influence the results returned. */
message: Omit<DwnMessageParams[DwnInterface.ProtocolsQuery], 'signer'>
}
/**
* Wraps the response from a protocols query, including the operation status and the list of
* protocols.
*/
export type ProtocolsQueryResponse = DwnResponseStatus & {
/** Array of protocols matching the query. */
protocols: Protocol[];
}
/**
* Type alias for {@link RecordsWriteRequest}
*/
export type RecordsCreateRequest = RecordsWriteRequest;
/**
* Type alias for {@link RecordsWriteResponse}
*/
export type RecordsCreateResponse = RecordsWriteResponse;
/**
* Represents a request to create a new record based on an existing one.
*
* This request type allows specifying the new data for the record, along with any additional
* message parameters required for the write operation.
*/
export type RecordsCreateFromRequest = {
/** The DID of the entity authoring the record. */
author: string;
/** The new data for the record. */
data: unknown;
/** ptional additional parameters for the record write operation */
message?: Omit<DwnMessageParams[DwnInterface.RecordsWrite], 'signer'>;
/** The existing record instance that is being used as a basis for the new record. */
record: Record;
}
/**
* Defines a request to delete a record from the Decentralized Web Node (DWN).
*
* This request type optionally specifies the target from which the record should be deleted and the
* message parameters for the delete operation. If the `from` property is not provided, the record
* will be deleted from the local DWN.
*/
export type RecordsDeleteRequest = {
/** Optional DID specifying the remote target DWN tenant the record will be deleted from. */
from?: string;
/** The parameters for the delete operation. */
message: Omit<DwnMessageParams[DwnInterface.RecordsDelete], 'signer'>;
}
/**
* Encapsulates a request to query records from a Decentralized Web Node (DWN).
*
* This request type is used to specify the criteria for querying records, including query
* parameters, and optionally the target DWN to query from. If the `from` property is not provided,
* the query will target the local DWN.
*/
export type RecordsQueryRequest = {
/** Optional DID specifying the remote target DWN tenant to query from and return results. */
from?: string;
/** The parameters for the query operation, detailing the criteria for selecting records. */
message: Omit<DwnMessageParams[DwnInterface.RecordsQuery], 'signer'>;
}
/**
* Represents the response from a records query operation, including status, records, and an
* optional pagination cursor.
*/
export type RecordsQueryResponse = DwnResponseStatus & {
/** Array of records matching the query. */
records?: Record[]
/** If there are additional results, the messageCid of the last record will be returned as a pagination cursor. */
cursor?: DwnPaginationCursor;
};
/**
* Represents a request to read a specific record from a Decentralized Web Node (DWN).
*
* This request type is used to specify the target DWN from which the record should be read and any
* additional parameters for the read operation. It's useful for fetching the details of a single
* record by its identifier or other criteria.
*/
export type RecordsReadRequest = {
/** Optional DID specifying the remote target DWN tenant the record will be read from. */
from?: string;
/** The parameters for the read operation, detailing the criteria for selecting the record. */
message: Omit<DwnMessageParams[DwnInterface.RecordsRead], 'signer'>;
}
/**
* Encapsulates the response from a record read operation, combining the general operation status
* with the specific record that was retrieved.
*/
export type RecordsReadResponse = DwnResponseStatus & {
/** The record retrieved by the read operation. */
record: Record;
};
/**
* Defines a request to write (create) a record to a Decentralized Web Node (DWN).
*
* This request type allows specifying the data for the new or updated record, along with any
* additional message parameters required for the write operation, and an optional flag to indicate
* whether the record should be immediately stored.
*
* @param data -
* @param message - , excluding the signer.
* @param store -
*/
export type RecordsWriteRequest = {
/** The data payload for the record, which can be of any type. */
data: unknown;
/** Optional additional parameters for the record write operation. */
message?: Omit<Partial<DwnMessageParams[DwnInterface.RecordsWrite]>, 'signer'>;
/**
* Optional flag indicating whether the record should be immediately stored. If true, the record
* is persisted in the DWN as part of the write operation. If false, the record is created,
* signed, and returned but not persisted.
*/
store?: boolean;
}
/**
* Encapsulates the response from a record write operation to a Decentralized Web Node (DWN).
*
* This request type combines the general operation status with the details of the record that was
* written, if the operation was successful.
*
* The response includes a status object that contains the HTTP-like status code and detail message
* indicating the success or failure of the write operation. If the operation was successful and a
* record was created or updated, the `record` property will contain an instance of the `Record`
* class representing the written record. This allows the caller to access the written record's
* details and perform additional operations using the provided {@link Record} instance methods.
*/
export type RecordsWriteResponse = DwnResponseStatus & {
/**
* The `Record` instance representing the record that was successfully written to the
* DWN as a result of the write operation.
*/
record?: Record
};
/**
* Interface to interact with DWN Records and Protocols
*/
export class DwnApi {
/**
* Holds the instance of a {@link Web5Agent} that represents the current execution context for
* the `DwnApi`. This agent is used to process DWN requests.
*/
private agent: Web5Agent;
/** The DID of the DWN tenant under which operations are being performed. */
private connectedDid: string;
constructor(options: { agent: Web5Agent, connectedDid: string }) {
this.agent = options.agent;
this.connectedDid = options.connectedDid;
}
/**
* API to interact with DWN protocols (e.g., `dwn.protocols.configure()`).
*/
get protocols() {
return {
/**
* Configure method, used to setup a new protocol (or update) with the passed definitions
*/
configure: async (request: ProtocolsConfigureRequest): Promise<ProtocolsConfigureResponse> => {
const agentResponse = await this.agent.processDwnRequest({
author : this.connectedDid,
messageParams : request.message,
messageType : DwnInterface.ProtocolsConfigure,
target : this.connectedDid
});
const { message, messageCid, reply: { status }} = agentResponse;
const response: ProtocolsConfigureResponse = { status };
if (status.code < 300) {
const metadata = { author: this.connectedDid, messageCid };
response.protocol = new Protocol(this.agent, message, metadata);
}
return response;
},
/**
* Query the available protocols
*/
query: async (request: ProtocolsQueryRequest): Promise<ProtocolsQueryResponse> => {
const agentRequest: ProcessDwnRequest<DwnInterface.ProtocolsQuery> = {
author : this.connectedDid,
messageParams : request.message,
messageType : DwnInterface.ProtocolsQuery,
target : request.from || this.connectedDid
};
let agentResponse: DwnResponse<DwnInterface.ProtocolsQuery>;
if (request.from) {
agentResponse = await this.agent.sendDwnRequest(agentRequest);
} else {
agentResponse = await this.agent.processDwnRequest(agentRequest);
}
const reply = agentResponse.reply;
const { entries = [], status } = reply;
const protocols = entries.map((entry) => {
const metadata = { author: this.connectedDid };
return new Protocol(this.agent, entry, metadata);
});
return { protocols, status };
}
};
}
/**
* API to interact with DWN records (e.g., `dwn.records.create()`).
*/
get records() {
return {
/**
* Alias for the `write` method
*/
create: async (request: RecordsCreateRequest): Promise<RecordsCreateResponse> => {
return this.records.write(request);
},
/**
* Write a record based on an existing one (useful for updating an existing record)
*/
createFrom: async (request: RecordsCreateFromRequest): Promise<RecordsWriteResponse> => {
const { author: inheritedAuthor, ...inheritedProperties } = request.record.toJSON();
// If `data` is being updated then `dataCid` and `dataSize` must not be present.
if (request.data !== undefined) {
delete inheritedProperties.dataCid;
delete inheritedProperties.dataSize;
}
// If `published` is set to false, ensure that `datePublished` is undefined. Otherwise, DWN SDK's schema validation
// will throw an error if `published` is false but `datePublished` is set.
if (request.message?.published === false && inheritedProperties.datePublished !== undefined) {
delete inheritedProperties.datePublished;
delete inheritedProperties.published;
}
// If the request changes the `author` or message `descriptor` then the deterministic `recordId` will change.
// As a result, we will discard the `recordId` if either of these changes occur.
if (!isEmptyObject(request.message) || (request.author && request.author !== inheritedAuthor)) {
delete inheritedProperties.recordId;
}
return this.records.write({
data : request.data,
message : {
...inheritedProperties,
...request.message,
},
});
},
/**
* Delete a record
*/
delete: async (request: RecordsDeleteRequest): Promise<DwnResponseStatus> => {
const agentRequest: ProcessDwnRequest<DwnInterface.RecordsDelete> = {
/**
* The `author` is the DID that will sign the message and must be the DID the Web5 app is
* connected with and is authorized to access the signing private key of.
*/
author : this.connectedDid,
messageParams : request.message,
messageType : DwnInterface.RecordsDelete,
/**
* The `target` is the DID of the DWN tenant under which the delete will be executed.
* If `from` is provided, the delete operation will be executed on a remote DWN.
* Otherwise, the record will be deleted on the local DWN.
*/
target : request.from || this.connectedDid
};
let agentResponse: DwnResponse<DwnInterface.RecordsDelete>;
if (request.from) {
agentResponse = await this.agent.sendDwnRequest(agentRequest);
} else {
agentResponse = await this.agent.processDwnRequest(agentRequest);
}
const { reply: { status } } = agentResponse;
return { status };
},
/**
* Query a single or multiple records based on the given filter
*/
query: async (request: RecordsQueryRequest): Promise<RecordsQueryResponse> => {
const agentRequest: ProcessDwnRequest<DwnInterface.RecordsQuery> = {
/**
* The `author` is the DID that will sign the message and must be the DID the Web5 app is
* connected with and is authorized to access the signing private key of.
*/
author : this.connectedDid,
messageParams : request.message,
messageType : DwnInterface.RecordsQuery,
/**
* The `target` is the DID of the DWN tenant under which the query will be executed.
* If `from` is provided, the query operation will be executed on a remote DWN.
* Otherwise, the local DWN will be queried.
*/
target : request.from || this.connectedDid
};
let agentResponse: DwnResponse<DwnInterface.RecordsQuery>;
if (request.from) {
agentResponse = await this.agent.sendDwnRequest(agentRequest);
} else {
agentResponse = await this.agent.processDwnRequest(agentRequest);
}
const reply = agentResponse.reply;
const { entries, status, cursor } = reply;
const records = entries.map((entry) => {
const recordOptions = {
/**
* Extract the `author` DID from the record entry since records may be signed by the
* tenant owner or any other entity.
*/
author : getRecordAuthor(entry),
/**
* Set the `connectedDid` to currently connected DID so that subsequent calls to
* {@link Record} instance methods, such as `record.update()` are executed on the
* local DWN even if the record was returned by a query of a remote DWN.
*/
connectedDid : this.connectedDid,
/**
* If the record was returned by a query of a remote DWN, set the `remoteOrigin` to
* the DID of the DWN that returned the record. The `remoteOrigin` property will be used
* to determine which DWN to send subsequent read requests to in the event the data
* payload exceeds the threshold for being returned with queries.
*/
remoteOrigin : request.from,
...entry as DwnMessage[DwnInterface.RecordsWrite]
};
const record = new Record(this.agent, recordOptions);
return record;
});
return { records, status, cursor };
},
/**
* Read a single record based on the given filter
*/
read: async (request: RecordsReadRequest): Promise<RecordsReadResponse> => {
const agentRequest: ProcessDwnRequest<DwnInterface.RecordsRead> = {
/**
* The `author` is the DID that will sign the message and must be the DID the Web5 app is
* connected with and is authorized to access the signing private key of.
*/
author : this.connectedDid,
messageParams : request.message,
messageType : DwnInterface.RecordsRead,
/**
* The `target` is the DID of the DWN tenant under which the read will be executed.
* If `from` is provided, the read operation will be executed on a remote DWN.
* Otherwise, the read will occur on the local DWN.
*/
target : request.from || this.connectedDid
};
let agentResponse: DwnResponse<DwnInterface.RecordsRead>;
if (request.from) {
agentResponse = await this.agent.sendDwnRequest(agentRequest);
} else {
agentResponse = await this.agent.processDwnRequest(agentRequest);
}
const { reply: { record: responseRecord, status } } = agentResponse;
let record: Record;
if (200 <= status.code && status.code <= 299) {
const recordOptions = {
/**
* Extract the `author` DID from the record since records may be signed by the
* tenant owner or any other entity.
*/
author : getRecordAuthor(responseRecord),
/**
* Set the `connectedDid` to currently connected DID so that subsequent calls to
* {@link Record} instance methods, such as `record.update()` are executed on the
* local DWN even if the record was read from a remote DWN.
*/
connectedDid : this.connectedDid,
/**
* If the record was returned by reading from a remote DWN, set the `remoteOrigin` to
* the DID of the DWN that returned the record. The `remoteOrigin` property will be used
* to determine which DWN to send subsequent read requests to in the event the data
* payload must be read again (e.g., if the data stream is consumed).
*/
remoteOrigin : request.from,
...responseRecord,
};
record = new Record(this.agent, recordOptions);
}
return { record, status };
},
/**
* Writes a record to the DWN
*
* As a convenience, the Record instance returned will cache a copy of the data. This is done
* to maintain consistency with other DWN methods, like RecordsQuery, that include relatively
* small data payloads when returning RecordsWrite message properties. Regardless of data
* size, methods such as `record.data.stream()` will return the data when called even if it
* requires fetching from the DWN datastore.
*/
write: async (request: RecordsWriteRequest): Promise<RecordsWriteResponse> => {
const { dataBlob, dataFormat } = dataToBlob(request.data, request.message?.dataFormat);
const agentResponse = await this.agent.processDwnRequest({
author : this.connectedDid,
dataStream : dataBlob,
messageParams : { ...request.message, dataFormat },
messageType : DwnInterface.RecordsWrite,
store : request.store,
target : this.connectedDid
});
const { message: responseMessage, reply: { status } } = agentResponse;
let record: Record;
if (200 <= status.code && status.code <= 299) {
const recordOptions = {
/**
* Assume the author is the connected DID since the record was just written to the
* local DWN.
*/
author : this.connectedDid,
/**
* Set the `connectedDid` to currently connected DID so that subsequent calls to
* {@link Record} instance methods, such as `record.update()` are executed on the
* local DWN.
*/
connectedDid : this.connectedDid,
encodedData : dataBlob,
...responseMessage,
};
record = new Record(this.agent, recordOptions);
}
return { record, status };
},
};
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Making developing with Web5 components at least 5 times easier to work with.
*
* Web5 consists of the following components:
* - Decentralized Identifiers
* - Verifiable Credentials
* - DWeb Node personal datastores
*
* The SDK sets out to gather the most oft used functionality from all three of
* these pillar technologies to provide a simple library that is as close to
* effortless as possible.
*
* The SDK is currently still under active development, but having entered the
* Tech Preview phase there is now a drive to avoid unnecessary changes unless
* backwards compatibility is provided. Additional functionality will be added
* in the lead up to 1.0 final, and modifications will be made to address
* issues and community feedback.
*
* [Link to GitHub Repo](https://github.com/TBD54566975/web5-js)
*
* @packageDocumentation
*/
export * from './did-api.js';
export * from './dwn-api.js';
export * from './protocol.js';
export * from './record.js';
export * from './vc-api.js';
export * from './web5.js';
export * from './tech-preview.js';
export * from './web-features.js';
import * as utils from './utils.js';
export { utils };
+87
View File
@@ -0,0 +1,87 @@
/**
* NOTE: Added reference types here to avoid a `pnpm` bug during build.
* https://github.com/TBD54566975/web5-js/pull/507
*/
/// <reference types="@tbd54566975/dwn-sdk-js" />
import type { DwnMessage, DwnResponseStatus, Web5Agent } from '@web5/agent';
import { DwnInterface } from '@web5/agent';
/**
* Represents metadata associated with a protocol, including the author and an optional message CID.
*/
export type ProtocolMetadata = {
/** The author of the protocol. */
author: string;
/**
* The Content Identifier (CID) of a ProtocolsConfigure message.
*
* This is an optional field, and is used by {@link Protocol.send}.
*/
messageCid?: string;
};
/**
* Encapsulates a DWN Protocol with its associated metadata and configuration.
*
* This class primarly exists to provide developers with a convenient way to configure/install
* protocols on remote DWNs.
*/
export class Protocol {
/** The {@link Web5Agent} instance that handles DWNs requests. */
private _agent: Web5Agent;
/** The ProtocolsConfigureMessage containing the detailed configuration for the protocol. */
private _metadata: ProtocolMetadata;
/** Metadata associated with the protocol, including the author and optional message CID. */
private _protocolsConfigureMessage: DwnMessage[DwnInterface.ProtocolsConfigure];
/**
* Constructs a new instance of the Protocol class.
*
* @param agent - The Web5Agent instance used for network interactions.
* @param protocolsConfigureMessage - The configuration message containing the protocol details.
* @param metadata - Metadata associated with the protocol, including the author and optional message CID.
*/
constructor(agent: Web5Agent, protocolsConfigureMessage: DwnMessage[DwnInterface.ProtocolsConfigure], metadata: ProtocolMetadata) {
this._agent = agent;
this._metadata = metadata;
this._protocolsConfigureMessage = protocolsConfigureMessage;
}
/**
* Retrieves the protocol definition from the protocol's configuration message.
* @returns The protocol definition.
*/
get definition() {
return this._protocolsConfigureMessage.descriptor.definition;
}
/**
* Serializes the protocol's configuration message to JSON.
* @returns The serialized JSON object of the protocol's configuration message.
*/
toJSON() {
return this._protocolsConfigureMessage;
}
/**
* Sends the protocol configuration to a remote DWN identified by the target DID.
*
* @param target - The DID of the target DWN to which the protocol configuration will be installed.
* @returns A promise that resolves to an object containing the status of the send operation.
*/
async send(target: string): Promise<DwnResponseStatus> {
const { reply } = await this._agent.sendDwnRequest({
author : this._metadata.author,
messageCid : this._metadata.messageCid,
messageType : DwnInterface.ProtocolsConfigure,
target : target,
});
return { status: reply.status };
}
}
+795
View File
@@ -0,0 +1,795 @@
/**
* NOTE: Added reference types here to avoid a `pnpm` bug during build.
* https://github.com/TBD54566975/web5-js/pull/507
*/
/// <reference types="@tbd54566975/dwn-sdk-js" />
import type { Readable } from '@web5/common';
import {
Web5Agent,
DwnMessage,
DwnMessageParams,
DwnResponseStatus,
ProcessDwnRequest,
DwnMessageDescriptor,
getPaginationCursor,
DwnDateSort,
DwnPaginationCursor
} from '@web5/agent';
import { DwnInterface } from '@web5/agent';
import { Convert, isEmptyObject, NodeStream, removeUndefinedProperties, Stream } from '@web5/common';
import { dataToBlob, SendCache } from './utils.js';
/**
* Represents the structured data model of a record, encapsulating the essential fields that define
* the record's metadata and payload within a Decentralized Web Node (DWN).
*
* @beta
*/
export type RecordModel = DwnMessageDescriptor[DwnInterface.RecordsWrite]
& Omit<DwnMessage[DwnInterface.RecordsWrite], 'descriptor' | 'recordId'>
& {
/** The DID that signed the record. */
author: string;
/** The protocol role under which this record is written. */
protocolRole?: RecordOptions['protocolRole'];
/** The unique identifier of the record. */
recordId?: string;
}
/**
* Options for configuring a {@link Record} instance, extending the base `RecordsWriteMessage` with
* additional properties.
*
* This type combines the standard fields required for writing DWN records with additional metadata
* and configuration options used specifically in the {@link Record} class.
*
* @beta
*/
export type RecordOptions = DwnMessage[DwnInterface.RecordsWrite] & {
/** The DID that signed the record. */
author: string;
/** The DID of the DWN tenant under which record operations are being performed. */
connectedDid: string;
/** The data of the record, either as a Base64 URL encoded string or a Blob. */
encodedData?: string | Blob;
/**
* A stream of data, conforming to the `Readable` or `ReadableStream` interface, providing a
* mechanism to read the record's data sequentially. This is particularly useful for handling
* large datasets that should not be loaded entirely in memory, allowing for efficient, chunked
* processing of the record's data.
*/
data?: Readable | ReadableStream;
/** The initial `RecordsWriteMessage` that represents the initial state/version of the record. */
initialWrite?: DwnMessage[DwnInterface.RecordsWrite];
/** The protocol role under which this record is written. */
protocolRole?: string;
/** The remote tenant DID if the record was queried or read from a remote DWN. */
remoteOrigin?: string;
};
/**
* Parameters for updating a DWN record.
*
* This type specifies the set of properties that can be updated on an existing record. It is used
* to convey the new state or changes to be applied to the record.
*
* @beta
*/
export type RecordUpdateParams = {
/**
* The new data for the record, which can be of any type. This data will replace the existing
* data of the record. It's essential to ensure that this data is compatible with the record's
* schema or data format expectations.
*/
data?: unknown;
/**
* The Content Identifier (CID) of the data. Updating this value changes the reference to the data
* associated with the record.
*/
dataCid?: DwnMessageDescriptor[DwnInterface.RecordsWrite]['dataCid'];
/** The size of the data in bytes. */
dataSize?: DwnMessageDescriptor[DwnInterface.RecordsWrite]['dataSize'];
/** The timestamp indicating when the record was last modified. */
dateModified?: DwnMessageDescriptor[DwnInterface.RecordsWrite]['messageTimestamp'];
/** The timestamp indicating when the record was published. */
datePublished?: DwnMessageDescriptor[DwnInterface.RecordsWrite]['datePublished'];
/** The protocol role under which this record is written. */
protocolRole?: RecordOptions['protocolRole'];
/** The published status of the record. */
published?: DwnMessageDescriptor[DwnInterface.RecordsWrite]['published'];
/** The tags associated with the updated record */
tags?: DwnMessageDescriptor[DwnInterface.RecordsWrite]['tags'];
}
/**
* Record wrapper class with convenience methods to send and update,
* aside from manipulating and reading the record data.
*
* Note: The `messageTimestamp` of the most recent RecordsWrite message is
* logically equivalent to the date/time at which a Record was most
* recently modified. Since this Record class implementation is
* intended to simplify the developer experience of working with
* logical records (and not individual DWN messages) the
* `messageTimestamp` is mapped to `dateModified`.
*
* @beta
*/
/**
* The `Record` class encapsulates a single record's data and metadata, providing a more
* developer-friendly interface for working with Decentralized Web Node (DWN) records.
*
* Methods are provided to read, update, and manage the record's lifecycle, including writing to
* remote DWNs.
*
* @beta
*/
export class Record implements RecordModel {
/**
* Cache to minimize the amount of redundant two-phase commits we do in store() and send()
* Retains awareness of the last 100 records stored/sent for up to 100 target DIDs each.
*/
private static _sendCache = SendCache;
// Record instance metadata.
/** The {@link Web5Agent} instance that handles DWNs requests. */
private _agent: Web5Agent;
/** The DID of the DWN tenant under which operations are being performed. */
private _connectedDid: string;
/** Encoded data of the record, if available. */
private _encodedData?: Blob;
/** Stream of the record's data. */
private _readableStream?: Readable;
/** The origin DID if the record was fetched from a remote DWN. */
private _remoteOrigin?: string;
// Private variables for DWN `RecordsWrite` message properties.
/** The DID of the entity that authored the record. */
private _author: string;
/** Attestation JWS signature. */
private _attestation?: DwnMessage[DwnInterface.RecordsWrite]['attestation'];
/** Authorization signature(s). */
private _authorization?: DwnMessage[DwnInterface.RecordsWrite]['authorization'];
/** Context ID associated with the record. */
private _contextId?: string;
/** Descriptor detailing the record's schema, format, and other metadata. */
private _descriptor: DwnMessageDescriptor[DwnInterface.RecordsWrite];
/** Encryption details for the record, if the data is encrypted. */
private _encryption?: DwnMessage[DwnInterface.RecordsWrite]['encryption'];
/** Initial state of the record before any updates. */
private _initialWrite: RecordOptions['initialWrite'];
/** Flag indicating if the initial write has been stored, to prevent duplicates. */
private _initialWriteStored: boolean;
/** Flag indicating if the initial write has been signed by the owner. */
private _initialWriteSigned: boolean;
/** Unique identifier of the record. */
private _recordId: string;
/** Role under which the record is written. */
private _protocolRole: RecordOptions['protocolRole'];
// Getters for immutable DWN Record properties.
/** Record's signatures attestation */
get attestation(): DwnMessage[DwnInterface.RecordsWrite]['attestation'] { return this._attestation; }
/** Record's signatures attestation */
get authorization(): DwnMessage[DwnInterface.RecordsWrite]['authorization'] { return this._authorization; }
/** DID that signed the record. */
get author(): string { return this._author; }
/** Record's context ID */
get contextId() { return this._contextId; }
/** Record's data format */
get dataFormat() { return this._descriptor.dataFormat; }
/** Record's creation date */
get dateCreated() { return this._descriptor.dateCreated; }
/** Record's encryption */
get encryption(): DwnMessage[DwnInterface.RecordsWrite]['encryption'] { return this._encryption; }
/** Record's initial write if the record has been updated */
get initialWrite(): RecordOptions['initialWrite'] { return this._initialWrite; }
/** Record's ID */
get id() { return this._recordId; }
/** Interface is always `Records` */
get interface() { return this._descriptor.interface; }
/** Method is always `Write` */
get method() { return this._descriptor.method; }
/** Record's parent ID */
get parentId() { return this._descriptor.parentId; }
/** Record's protocol */
get protocol() { return this._descriptor.protocol; }
/** Record's protocol path */
get protocolPath() { return this._descriptor.protocolPath; }
/** Role under which the author is writing the record */
get protocolRole() { return this._protocolRole; }
/** Record's recipient */
get recipient() { return this._descriptor.recipient; }
/** Record's schema */
get schema() { return this._descriptor.schema; }
// Getters for mutable DWN Record properties.
/** Record's CID */
get dataCid() { return this._descriptor.dataCid; }
/** Record's data size */
get dataSize() { return this._descriptor.dataSize; }
/** Record's modified date */
get dateModified() { return this._descriptor.messageTimestamp; }
/** Record's published date */
get datePublished() { return this._descriptor.datePublished; }
/** Record's published status */
get messageTimestamp() { return this._descriptor.messageTimestamp; }
/** Record's published status (true/false) */
get published() { return this._descriptor.published; }
/** Tags of the record */
get tags() { return this._descriptor.tags; }
/**
* Returns a copy of the raw `RecordsWriteMessage` that was used to create the current `Record` instance.
*/
private get rawMessage(): DwnMessage[DwnInterface.RecordsWrite] {
const message = JSON.parse(JSON.stringify({
contextId : this._contextId,
recordId : this._recordId,
descriptor : this._descriptor,
attestation : this._attestation,
authorization : this._authorization,
encryption : this._encryption,
}));
removeUndefinedProperties(message);
return message;
}
constructor(agent: Web5Agent, options: RecordOptions) {
this._agent = agent;
// Store the author DID that originally signed the message as a convenience for developers, so
// that they don't have to decode the signer's DID from the JWS.
this._author = options.author;
// Store the currently `connectedDid` so that subsequent message signing is done with the
// connected DID's keys and DWN requests target the connected DID's DWN.
this._connectedDid = options.connectedDid;
// If the record was queried or read from a remote DWN, the `remoteOrigin` DID will be
// defined. This value is used to send subsequent read requests to the same remote DWN in the
// event the record's data payload was too large to be returned in query results. or must be
// read again (e.g., if the data stream is consumed).
this._remoteOrigin = options.remoteOrigin;
// RecordsWriteMessage properties.
this._attestation = options.attestation;
this._authorization = options.authorization;
this._contextId = options.contextId;
this._descriptor = options.descriptor;
this._encryption = options.encryption;
this._initialWrite = options.initialWrite;
this._recordId = options.recordId;
this._protocolRole = options.protocolRole;
if (options.encodedData) {
// If `encodedData` is set, then it is expected that:
// type is Blob if the Record object was instantiated by dwn.records.create()/write().
// type is Base64 URL encoded string if the Record object was instantiated by dwn.records.query().
// If it is a string, we need to Base64 URL decode to bytes and instantiate a Blob.
this._encodedData = (typeof options.encodedData === 'string') ?
new Blob([Convert.base64Url(options.encodedData).toUint8Array()], { type: this.dataFormat }) :
options.encodedData;
}
if (options.data) {
// If the record was created from a RecordsRead reply then it will have a `data` property.
// If the `data` property is a web ReadableStream, convert it to a Node.js Readable.
this._readableStream = Stream.isReadableStream(options.data) ?
NodeStream.fromWebReadable({ readableStream: options.data }) :
options.data;
}
}
/**
* Returns the data of the current record.
* If the record data is not available, it attempts to fetch the data from the DWN.
* @returns a data stream with convenience methods such as `blob()`, `json()`, `text()`, and `stream()`, similar to the fetch API response
* @throws `Error` if the record has already been deleted.
*
* @beta
*/
get data() {
const self = this; // Capture the context of the `Record` instance.
const dataObj = {
/**
* Returns the data of the current record as a `Blob`.
*
* @returns A promise that resolves to a Blob containing the record's data.
* @throws If the record data is not available or cannot be converted to a `Blob`.
*
* @beta
*/
async blob(): Promise<Blob> {
return new Blob([await NodeStream.consumeToBytes({ readable: await this.stream() })], { type: self.dataFormat });
},
/**
* Returns the data of the current record as a `Uint8Array`.
*
* @returns A Promise that resolves to a `Uint8Array` containing the record's data bytes.
* @throws If the record data is not available or cannot be converted to a byte array.
*
* @beta
*/
async bytes(): Promise<Uint8Array> {
return await NodeStream.consumeToBytes({ readable: await this.stream() });
},
/**
* Parses the data of the current record as JSON and returns it as a JavaScript object.
*
* @returns A Promise that resolves to a JavaScript object parsed from the record's JSON data.
* @throws If the record data is not available, not in JSON format, or cannot be parsed.
*
* @beta
*/
async json(): Promise<any> {
return await NodeStream.consumeToJson({ readable: await this.stream() });
},
/**
* Returns the data of the current record as a `string`.
*
* @returns A promise that resolves to a `string` containing the record's text data.
* @throws If the record data is not available or cannot be converted to text.
*
* @beta
*/
async text(): Promise<string> {
return await NodeStream.consumeToText({ readable: await this.stream() });
},
/**
* Provides a `Readable` stream containing the record's data.
*
* @returns A promise that resolves to a Node.js `Readable` stream of the record's data.
* @throws If the record data is not available in-memory and cannot be fetched.
*
* @beta
*/
async stream(): Promise<Readable> {
if (self._encodedData) {
/** If `encodedData` is set, it indicates that the Record was instantiated by
* `dwn.records.create()`/`dwn.records.write()` or the record's data payload was small
* enough to be returned in `dwn.records.query()` results. In either case, the data is
* already available in-memory and can be returned as a Node.js `Readable` stream. */
self._readableStream = NodeStream.fromWebReadable({ readableStream: self._encodedData.stream() });
} else if (!NodeStream.isReadable({ readable: self._readableStream })) {
/** If the data stream for this `Record` instance has already been partially or fully
* consumed, then the data must be fetched again from either: */
self._readableStream = self._remoteOrigin ?
// A. ...a remote DWN if the record was originally queried from a remote DWN.
await self.readRecordData({ target: self._remoteOrigin, isRemote: true }) :
// B. ...a local DWN if the record was originally queried from the local DWN.
await self.readRecordData({ target: self._connectedDid, isRemote: false });
}
if (!self._readableStream) {
throw new Error('Record data is not available.');
}
return self._readableStream;
},
/**
* Attaches callbacks for the resolution and/or rejection of the `Promise` returned by
* `stream()`.
*
* This method is a proxy to the `then` method of the `Promise` returned by `stream()`,
* allowing for a seamless integration with promise-based workflows.
* @param onFulfilled - A function to asynchronously execute when the `stream()` promise
* becomes fulfilled.
* @param onRejected - A function to asynchronously execute when the `stream()` promise
* becomes rejected.
* @returns A `Promise` for the completion of which ever callback is executed.
*/
then(onFulfilled?: (value: Readable) => Readable | PromiseLike<Readable>, onRejected?: (reason: any) => PromiseLike<never>) {
return this.stream().then(onFulfilled, onRejected);
},
/**
* Attaches a rejection handler callback to the `Promise` returned by the `stream()` method.
* This method is a shorthand for `.then(undefined, onRejected)`, specifically designed for handling
* rejection cases in the promise chain initiated by accessing the record's data. It ensures that
* errors during data retrieval or processing can be caught and handled appropriately.
*
* @param onRejected - A function to asynchronously execute when the `stream()` promise
* becomes rejected.
* @returns A `Promise` that resolves to the value of the callback if it is called, or to its
* original fulfillment value if the promise is instead fulfilled.
*/
catch(onRejected?: (reason: any) => PromiseLike<never>) {
return this.stream().catch(onRejected);
}
};
return dataObj;
}
/**
* Stores the current record state as well as any initial write to the owner's DWN.
*
* @param importRecord - if true, the record will signed by the owner before storing it to the owner's DWN. Defaults to false.
* @returns the status of the store request
*
* @beta
*/
async store(importRecord: boolean = false): Promise<DwnResponseStatus> {
// if we are importing the record we sign it as the owner
return this.processRecord({ signAsOwner: importRecord, store: true });
}
/**
* Signs the current record state as well as any initial write and optionally stores it to the owner's DWN.
* This is useful when importing a record that was signed by someone else into your own DWN.
*
* @param store - if true, the record will be stored to the owner's DWN after signing. Defaults to true.
* @returns the status of the import request
*
* @beta
*/
async import(store: boolean = true): Promise<DwnResponseStatus> {
return this.processRecord({ store, signAsOwner: true });
}
/**
* Send the current record to a remote DWN by specifying their DID
* If no DID is specified, the target is assumed to be the owner (connectedDID).
* If an initial write is present and the Record class send cache has no awareness of it, the initial write is sent first
* (vs waiting for the regular DWN sync)
*
* @param target - the optional DID to send the record to, if none is set it is sent to the connectedDid
* @returns the status of the send record request
* @throws `Error` if the record has already been deleted.
*
* @beta
*/
async send(target?: string): Promise<DwnResponseStatus> {
const initialWrite = this._initialWrite;
target ??= this._connectedDid;
// Is there an initial write? Do we know if we've already sent it to this target?
if (initialWrite && !Record._sendCache.check(this._recordId, target)){
// We do have an initial write, so prepare it for sending to the target.
const rawMessage = {
...initialWrite
};
removeUndefinedProperties(rawMessage);
// Send the initial write to the target.
await this._agent.sendDwnRequest({
messageType : DwnInterface.RecordsWrite,
author : this._connectedDid,
target : target,
rawMessage
});
// Set the cache to maintain awareness that we don't need to send the initial write next time.
Record._sendCache.set(this._recordId, target);
}
// Send the current/latest state to the target.
const { reply } = await this._agent.sendDwnRequest({
messageType : DwnInterface.RecordsWrite,
author : this._connectedDid,
dataStream : await this.data.blob(),
target : target,
rawMessage : { ...this.rawMessage }
});
return reply;
}
/**
* Returns a JSON representation of the Record instance.
* It's called by `JSON.stringify(...)` automatically.
*/
toJSON(): RecordModel {
return {
attestation : this.attestation,
author : this.author,
authorization : this.authorization,
contextId : this.contextId,
dataCid : this.dataCid,
dataFormat : this.dataFormat,
dataSize : this.dataSize,
dateCreated : this.dateCreated,
messageTimestamp : this.dateModified,
datePublished : this.datePublished,
encryption : this.encryption,
interface : this.interface,
method : this.method,
parentId : this.parentId,
protocol : this.protocol,
protocolPath : this.protocolPath,
protocolRole : this.protocolRole,
published : this.published,
recipient : this.recipient,
recordId : this.id,
schema : this.schema,
tags : this.tags,
};
}
/**
* Convenience method to return the string representation of the Record instance.
* Called automatically in string concatenation, String() type conversion, and template literals.
*/
toString() {
let str = `Record: {\n`;
str += ` ID: ${this.id}\n`;
str += this.contextId ? ` Context ID: ${this.contextId}\n` : '';
str += this.protocol ? ` Protocol: ${this.protocol}\n` : '';
str += this.schema ? ` Schema: ${this.schema}\n` : '';
str += ` Data CID: ${this.dataCid}\n`;
str += ` Data Format: ${this.dataFormat}\n`;
str += ` Data Size: ${this.dataSize}\n`;
str += ` Created: ${this.dateCreated}\n`;
str += ` Modified: ${this.dateModified}\n`;
str += `}`;
return str;
}
/**
* Returns a pagination cursor for the current record given a sort order.
*
* @param sort the sort order to use for the pagination cursor.
* @returns A promise that resolves to a pagination cursor for the current record.
*/
async paginationCursor(sort: DwnDateSort): Promise<DwnPaginationCursor> {
return getPaginationCursor(this.rawMessage, sort);
}
/**
* Update the current record on the DWN.
* @param params - Parameters to update the record.
* @returns the status of the update request
* @throws `Error` if the record has already been deleted.
*
* @beta
*/
async update({ dateModified, data, ...params }: RecordUpdateParams): Promise<DwnResponseStatus> {
// if there is a parentId, we remove it from the descriptor and set a parentContextId
const { parentId, ...descriptor } = this._descriptor;
const parentContextId = parentId ? this._contextId.split('/').slice(0, -1).join('/') : undefined;
// Begin assembling the update message.
let updateMessage: DwnMessageParams[DwnInterface.RecordsWrite] = {
...descriptor,
...params,
parentContextId,
messageTimestamp : dateModified, // Map Record class `dateModified` property to DWN SDK `messageTimestamp`
recordId : this._recordId
};
// NOTE: The original Record's tags are copied to the update message, so that the tags are not lost.
// However if a user passes new tags in the `RecordUpdateParams` object, they will overwrite the original tags.
// If the updated tag object is empty or set to null, we remove the tags property to avoid schema validation errors in the DWN SDK.
if (isEmptyObject(updateMessage.tags) || updateMessage.tags === null) {
delete updateMessage.tags;
}
let dataBlob: Blob;
if (data !== undefined) {
// If `data` is being updated then `dataCid` and `dataSize` must be undefined and the `data`
// value must be converted to a Blob and later passed as a top-level property to
// `agent.processDwnRequest()`.
delete updateMessage.dataCid;
delete updateMessage.dataSize;
({ dataBlob } = dataToBlob(data, updateMessage.dataFormat));
}
// Throw an error if an attempt is made to modify immutable properties.
// Note: `data` and `dateModified` have already been handled.
const mutableDescriptorProperties = new Set(['data', 'dataCid', 'dataSize', 'datePublished', 'messageTimestamp', 'published', 'tags']);
Record.verifyPermittedMutation(Object.keys(params), mutableDescriptorProperties);
// If `published` is set to false, ensure that `datePublished` is undefined. Otherwise, DWN SDK's schema validation
// will throw an error if `published` is false but `datePublished` is set.
if (params.published === false && updateMessage.datePublished !== undefined) {
delete updateMessage.datePublished;
}
const agentResponse = await this._agent.processDwnRequest({
author : this._connectedDid,
dataStream : dataBlob,
messageParams : { ...updateMessage },
messageType : DwnInterface.RecordsWrite,
target : this._connectedDid,
});
const { message, reply: { status } } = agentResponse;
const responseMessage = message;
if (200 <= status.code && status.code <= 299) {
// copy the original raw message to the initial write before we update the values.
if (!this._initialWrite) {
this._initialWrite = { ...this.rawMessage };
}
// Only update the local Record instance mutable properties if the record was successfully (over)written.
this._authorization = responseMessage.authorization;
this._protocolRole = params.protocolRole;
mutableDescriptorProperties.forEach(property => {
this._descriptor[property] = responseMessage.descriptor[property];
});
// Cache data.
if (data !== undefined) {
this._encodedData = dataBlob;
}
}
return { status };
}
/**
* Handles the various conditions around there being an initial write, whether to store initial/current state,
* and whether to add an owner signature to the initial write to enable storage when protocol rules require it.
*/
private async processRecord({ store, signAsOwner }:{ store: boolean, signAsOwner: boolean }): Promise<DwnResponseStatus> {
// if there is an initial write and we haven't already processed it, we first process it and marked it as such.
if (this._initialWrite && ((signAsOwner && !this._initialWriteSigned) || (store && !this._initialWriteStored))) {
const initialWriteRequest: ProcessDwnRequest<DwnInterface.RecordsWrite> = {
messageType : DwnInterface.RecordsWrite,
rawMessage : this.initialWrite,
author : this._connectedDid,
target : this._connectedDid,
signAsOwner,
store,
};
// Process the prepared initial write, with the options set for storing and/or signing as the owner.
const agentResponse = await this._agent.processDwnRequest(initialWriteRequest);
const { message, reply: { status } } = agentResponse;
const responseMessage = message;
// If we are signing as owner, make sure to update the initial write's authorization, because now it will have the owner's signature on it
// set the stored or signed status to true so we don't process it again.
if (200 <= status.code && status.code <= 299) {
if (store) this._initialWriteStored = true;
if (signAsOwner) {
this._initialWriteSigned = true;
this.initialWrite.authorization = responseMessage.authorization;
}
}
}
// Now that we've processed a potential initial write, we can process the current record state.
const requestOptions: ProcessDwnRequest<DwnInterface.RecordsWrite> = {
messageType : DwnInterface.RecordsWrite,
rawMessage : this.rawMessage,
author : this._connectedDid,
target : this._connectedDid,
dataStream : await this.data.blob(),
signAsOwner,
store,
};
const agentResponse = await this._agent.processDwnRequest(requestOptions);
const { message, reply: { status } } = agentResponse;
const responseMessage = message;
if (200 <= status.code && status.code <= 299) {
// If we are signing as the owner, make sure to update the current record state's authorization, because now it will have the owner's signature on it.
if (signAsOwner) this._authorization = responseMessage.authorization;
}
return { status };
}
/**
* Fetches the record's data from the specified DWN.
*
* This private method is called when the record data is not available in-memory
* and needs to be fetched from either a local or a remote DWN.
* It makes a read request to the specified DWN and processes the response to provide
* a Node.js `Readable` stream of the record's data.
*
* @param params - Parameters for fetching the record's data.
* @param params.target - The DID of the DWN to fetch the data from.
* @param params.isRemote - Indicates whether the target DWN is a remote node.
* @returns A Promise that resolves to a Node.js `Readable` stream of the record's data.
* @throws If there is an error while fetching or processing the data from the DWN.
*
* @beta
*/
private async readRecordData({ target, isRemote }: { target: string, isRemote: boolean }) {
const readRequest: ProcessDwnRequest<DwnInterface.RecordsRead> = {
author : this._connectedDid,
messageParams : { filter: { recordId: this.id } },
messageType : DwnInterface.RecordsRead,
target,
};
const agentResponsePromise = isRemote ?
this._agent.sendDwnRequest(readRequest) :
this._agent.processDwnRequest(readRequest);
try {
const { reply: { record }} = await agentResponsePromise;
const dataStream: ReadableStream | Readable = record.data;
// If the data stream is a web ReadableStream, convert it to a Node.js Readable.
const nodeReadable = Stream.isReadableStream(dataStream) ?
NodeStream.fromWebReadable({ readableStream: dataStream }) :
dataStream;
return nodeReadable;
} catch (error) {
throw new Error(`Error encountered while attempting to read data: ${error.message}`);
}
}
/**
* Verifies if the properties to be mutated are mutable.
*
* This private method is used to ensure that only mutable properties of the `Record` instance
* are being changed. It checks whether the properties specified for mutation are among the
* set of properties that are allowed to be modified. If any of the properties to be mutated
* are not in the set of mutable properties, the method throws an error.
*
* @param propertiesToMutate - An iterable of property names that are intended to be mutated.
* @param mutableDescriptorProperties - A set of property names that are allowed to be mutated.
*
* @throws If any of the properties in `propertiesToMutate` are not in `mutableDescriptorProperties`.
*
* @beta
*/
private static verifyPermittedMutation(propertiesToMutate: Iterable<string>, mutableDescriptorProperties: Set<string>) {
for (const property of propertiesToMutate) {
if (!mutableDescriptorProperties.has(property)) {
throw new Error(`${property} is an immutable property. Its value cannot be changed.`);
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import { UniversalResolver, DidDht, DidWeb } from '@web5/dids';
const workerSelf = self as any;
const DidResolver = new UniversalResolver({ didResolvers: [DidDht, DidWeb] });
const didUrlRegex = /^https?:\/\/dweb\/(([^/]+)\/.*)?$/;
const httpToHttpsRegex = /^http:/;
const trailingSlashRegex = /\/$/;
workerSelf.addEventListener('fetch', event => {
const match = event.request.url.match(didUrlRegex);
if (match) {
event.respondWith((async () => {
const normalizedUrl = event.request.url.replace(httpToHttpsRegex, 'https:').replace(trailingSlashRegex, '');
const cachedResponse = await caches.open('drl').then(cache => cache.match(normalizedUrl));
return cachedResponse || handleEvent(event, match[2], match[1]);
})());
}
});
async function handleEvent(event, did, route){
try {
const result = await DidResolver.resolve(did);
return await fetchResource(event, result.didDocument, route);
}
catch(error){
if (error instanceof Response) {
return error;
}
console.log(`Error in DID URL fetch: ${error}`);
return new Response('DID URL fetch error', { status: 500 });
}
}
async function fetchResource(event, ddo, route) {
let endpoints = ddo?.service?.find(service => service.type === 'DecentralizedWebNode')?.serviceEndpoint;
endpoints = (Array.isArray(endpoints) ? endpoints : [endpoints]).filter(url => url.startsWith('http'));
if (!endpoints?.length) {
throw new Response('DWeb Node resolution failed: no valid endpoints found.', { status: 530 });
}
for (const endpoint of endpoints) {
try {
const response = await fetch(`${endpoint.replace(trailingSlashRegex, '')}/${route}`, { headers: event.request.headers });
if (response.ok) {
return response;
}
console.log(`DWN endpoint error: ${response.status}`);
return new Response('DWeb Node request failed', { status: response.status });
}
catch (error) {
console.log(`DWN endpoint error: ${error}`);
return new Response('DWeb Node request failed: ' + error, { status: 500 });
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { utils as didUtils } from '@web5/dids';
/**
* Dynamically selects up to 2 DWN endpoints that are provided
* by default during the Tech Preview period.
*
* @beta
*/
export async function getTechPreviewDwnEndpoints(): Promise<string[]> {
let response: Response;
try {
response = await fetch('https://dwn.tbddev.org/.well-known/did.json');
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
}
} catch(error: any) {
console.warn('failed to get tech preview dwn endpoints:', error.message);
return [];
}
const didDocument = await response.json();
const [ dwnService ] = didUtils.getServices({ didDocument, id: '#dwn', type: 'DecentralizedWebNode' });
// allocate up to 2 nodes for a user.
const techPreviewEndpoints = new Set<string>();
if ('serviceEndpoint' in dwnService
&& !Array.isArray(dwnService.serviceEndpoint)
&& typeof dwnService.serviceEndpoint !== 'string'
&& Array.isArray(dwnService.serviceEndpoint.nodes)) {
const dwnUrls = dwnService.serviceEndpoint.nodes;
const numNodesToAllocate = Math.min(dwnUrls.length, 2);
for (let attempts = 0; attempts < dwnUrls.length && techPreviewEndpoints.size < numNodesToAllocate; attempts += 1) {
const nodeIdx = getRandomInt(0, dwnUrls.length);
const dwnUrl = dwnUrls[nodeIdx];
try {
const healthCheck = await fetch(`${dwnUrl}/health`);
if (healthCheck.ok) {
techPreviewEndpoints.add(dwnUrl);
}
} catch(error: unknown) {
// Ignore healthcheck failures and try the next node.
}
}
}
return Array.from(techPreviewEndpoints);
}
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min;
}
+128
View File
@@ -0,0 +1,128 @@
import { Convert, universalTypeOf } from '@web5/common';
/**
* Converts various data types to a `Blob` object, automatically detecting the data type or using
* the specified `dataFormat` to set the Blob's MIME type.
*
* This function supports plain text, JSON objects, binary data (Uint8Array, ArrayBuffer), and Blob
* inputs and will attempt to automatically detect the type of the data if `dataFormat` is not
* explicitly provided.
*
* @beta
*
* @example
* ```ts
* // Convert a JSON object to a Blob
* const { dataBlob, dataFormat } = dataToBlob({ key: 'value' }, 'application/json');
*
* // Convert a plain text string to a Blob without specifying dataFormat
* const { dataBlob: textBlob } = dataToBlob('Hello, world!');
*
* // Convert binary data to a Blob
* const binaryData = new Uint8Array([0, 1, 2, 3]);
* const { dataBlob: binaryBlob } = dataToBlob(binaryData);
* ```
*
* @param data - The data to be converted into a `Blob`. This can be a string, an object, binary
* data (Uint8Array or ArrayBuffer), or a Blob.
* @param dataFormat - An optional MIME type string that specifies the format of the data. Common
* types include 'text/plain' for string data, 'application/json' for JSON
* objects, and 'application/octet-stream' for binary data. If not provided, the
* function will attempt to detect the format based on the data type or default
* to 'application/octet-stream'.
* @returns An object containing the `dataBlob`, a Blob representation of the input data, and
* `dataFormat`, the MIME type of the data as determined by the function or specified by the caller.
* @throws An error if the data type is not supported or cannot be converted to a Blob.
*/
export function dataToBlob(data: any, dataFormat?: string): {
/** A Blob representation of the input data. */
dataBlob: Blob;
/** The MIME type of the data. */
dataFormat: string;
} {
let dataBlob: Blob;
// Check for Object or String, and if neither, assume bytes.
const detectedType = universalTypeOf(data);
if (dataFormat === 'text/plain' || detectedType === 'String') {
dataBlob = new Blob([data], { type: 'text/plain' });
} else if (dataFormat === 'application/json' || detectedType === 'Object') {
const dataBytes = Convert.object(data).toUint8Array();
dataBlob = new Blob([dataBytes], { type: 'application/json' });
} else if (detectedType === 'Uint8Array' || detectedType === 'ArrayBuffer') {
dataBlob = new Blob([data], { type: 'application/octet-stream' });
} else if (detectedType === 'Blob') {
dataBlob = data;
} else {
throw new Error('data type not supported.');
}
dataFormat = dataFormat || dataBlob.type || 'application/octet-stream';
return { dataBlob, dataFormat };
}
/**
* The `SendCache` class provides a static caching mechanism to optimize the process of sending
* records to remote DWN targets by minimizing redundant sends.
*
* It maintains a cache of record IDs and their associated target DIDs to which they have been sent.
* This helps in avoiding unnecessary network requests and ensures efficient data synchronization
* across Decentralized Web Nodes (DWNs).
*
* The cache employs a simple eviction policy to maintain a manageable size, ensuring that the cache
* does not grow indefinitely and consume excessive memory resources.
*
* @beta
*/
export class SendCache {
/**
* A private static map that serves as the core storage mechanism for the cache. It maps record
* IDs to a set of target DIDs, indicating which records have been sent to which targets.
*/
private static cache = new Map<string, Set<string>>();
/**
* The maximum number of entries allowed in the cache. Once this limit is exceeded, the oldest
* entries are evicted to make room for new ones. This limit applies both to the number of records
* and the number of targets per record.
*/
private static sendCacheLimit = 100;
/**
* Checks if a given record ID has been sent to a specified target DID. This method is used to
* determine whether a send operation is necessary or if it can be skipped to avoid redundancy.
*
* @param id - The unique identifier of the record.
* @param target - The DID of the target to check against.
* @returns A boolean indicating whether the record has been sent to the target.
*/
public static check(id: string, target: string): boolean {
let targetCache = SendCache.cache.get(id);
return targetCache ? targetCache.has(target) : false;
}
/**
* Adds or updates an entry in the cache for a given record ID and target DID. If the cache
* exceeds its size limit, the oldest entry is removed. This method ensures that the cache
* reflects the most recent sends.
*
* @param id - The unique identifier of the record.
* @param target - The DID of the target to which the record has been sent.
*/
public static set(id: string, target: string): void {
let targetCache = SendCache.cache.get(id) || new Set();
SendCache.cache.delete(id);
SendCache.cache.set(id, targetCache);
if (this.cache.size > SendCache.sendCacheLimit) {
const firstRecord = SendCache.cache.keys().next().value;
SendCache.cache.delete(firstRecord);
}
targetCache.delete(target);
targetCache.add(target);
if (targetCache.size > SendCache.sendCacheLimit) {
const firstTarget = targetCache.keys().next().value;
targetCache.delete(firstTarget);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { Web5Agent } from '@web5/agent';
/**
* The VC API is used to issue, present and verify VCs
*
* @beta
*/
export class VcApi {
/**
* Holds the instance of a {@link Web5Agent} that represents the current execution context for
* the `VcApi`. This agent is used to process VC requests.
*/
private agent: Web5Agent;
/** The DID of the tenant under which DID operations are being performed. */
private connectedDid: string;
constructor(options: { agent: Web5Agent, connectedDid: string }) {
this.agent = options.agent;
this.connectedDid = options.connectedDid;
}
/**
* Issues a VC (Not implemented yet)
*/
async create() {
// TODO: implement
throw new Error('Not implemented.');
}
}
+28
View File
@@ -0,0 +1,28 @@
declare const ServiceWorkerGlobalScope: any;
/**
* Installs the DWeb networking features in the current environment.
*/
export function installNetworkingFeatures(path: string): void {
const workerSelf = self as any;
try {
if (typeof ServiceWorkerGlobalScope !== 'undefined' && workerSelf instanceof ServiceWorkerGlobalScope) {
// Dynamically import service worker code only if we're in a Service Worker context
import('./service-worker.js').catch(error => {
console.error('Error loading service worker module:', error);
});
}
else if (globalThis?.navigator?.serviceWorker) {
if (path) navigator.serviceWorker.register(path).catch(error => {
console.error('DWeb networking feature installation failed: ', error);
});
}
else {
throw new Error('DWeb networking features are not available for install in this environment');
}
} catch (error) {
console.error('Error in installing networking features:', error);
}
}
+272
View File
@@ -0,0 +1,272 @@
import type { BearerIdentity, HdIdentityVault, Web5Agent } from '@web5/agent';
import { Web5UserAgent } from '@web5/user-agent';
import { VcApi } from './vc-api.js';
import { DwnApi } from './dwn-api.js';
import { DidApi } from './did-api.js';
import { getTechPreviewDwnEndpoints } from './tech-preview.js';
/** Override defaults configured during the technical preview phase. */
export type TechPreviewOptions = {
/** Override default dwnEndpoints provided for technical preview. */
dwnEndpoints?: string[];
}
/** Optional overrides that can be provided when calling {@link Web5.connect}. */
export type Web5ConnectOptions = {
/**
* Provide a {@link Web5Agent} implementation. Defaults to creating a local
* {@link Web5UserAgent} if one isn't provided
**/
agent?: Web5Agent;
/**
* Provide an instance of a {@link HdIdentityVault} implementation. Defaults to
* a LevelDB-backed store with an insecure, static unlock password if one
* isn't provided. To allow the app user to enter a secure password of
* their choosing, provide an initialized {@link HdIdentityVault} instance.
**/
agentVault?: HdIdentityVault;
/** Specify an existing DID to connect to. */
connectedDid?: string;
/**
* The Web5 app `password` is used to protect data on the device the application is running on.
*
* Only the end user should know this password: it should not be stored on the device or
* transmitted over the network.
*
* This password is crucial for the security of an identity vault that stores the local Agent's
* cryptographic keys and decentralized identifier (DID). The vault's content is encrypted using
* the password, making it accessible only to those who know the password.
*
* App users should be advised to use a strong, unique passphrase that is not shared across
* different services or applications. The password should be kept confidential and not be
* exposed to unauthorized entities. Losing the password may result in irreversible loss of
* access to the vault's contents.
*/
password?: string;
/**
* The `recoveryPhrase` is a unique, secure key for recovering the identity vault.
*
* This phrase is a series of 12 words generated securely and known only to the user. It plays a
* critical role in the security of the identity vault by enabling the recovery of the vault's
* contents, including cryptographic keys and the Agent's decentralized identifier (DID), across
* different devices or if the original device is compromised or lost.
*
* The recovery phrase is akin to a master key, as anyone with access to this phrase can restore
* and access the vault's contents. Its combined with the app `password` to encrypt the vault's
* content.
*
* Unlike a password, the recovery phrase is not intended for regular use but as a secure backup
* method for vault recovery. Losing this phrase can result in permanent loss of access to the
* vault's contents, as it cannot be reset or retrieved if forgotten.
*
* Users should treat the recovery phrase with the highest level of security, ensuring it is
* never shared, stored online, or exposed to potential threats. It is the user's responsibility
* to keep this phrase safe to maintain the integrity and accessibility of their secured data. It
* is recommended to write it down and store it in a secure location, separate from the device and
* digital backups.
*/
recoveryPhrase?: string;
/**
* Enable synchronization of DWN records between local and remote DWNs.
* Sync defaults to running every 2 minutes and can be set to any value accepted by `ms()`.
* To disable sync set to 'off'.
*/
sync?: string;
/**
* Override defaults configured during the technical preview phase.
* See {@link TechPreviewOptions} for available options.
*/
techPreview?: TechPreviewOptions;
}
/**
* Represents the result of the Web5 connection process, including the Web5 instance,
* the connected decentralized identifier (DID), and optionally the recovery phrase used
* during the agent's initialization.
*/
export type Web5ConnectResult = {
/** The Web5 instance, providing access to the agent, DID, DWN, and VC APIs. */
web5: Web5;
/** The DID that has been connected or created during the connection process. */
did: string;
/**
* The first time a Web5 agent is initialized, the recovery phrase that was used to generate the
* agent's DID and keys is returned. This phrase can be used to recover the agent's vault contents
* and should be stored securely by the user.
*/
recoveryPhrase?: string;
};
/**
* Parameters that are passed to Web5 constructor.
*
* @see {@link Web5ConnectOptions}
*/
export type Web5Params = {
/**
* A {@link Web5Agent} instance that handles DIDs, DWNs and VCs requests. The agent manages the
* user keys and identities, and is responsible to sign and verify messages.
*/
agent: Web5Agent;
/** The DID of the tenant under which all DID, DWN, and VC requests are being performed. */
connectedDid: string;
};
/**
* The main Web5 API interface. It manages the creation of a DID if needed, the connection to the
* local DWN and all the web5 main foundational APIs such as VC, syncing, etc.
*/
export class Web5 {
/**
* A {@link Web5Agent} instance that handles DIDs, DWNs and VCs requests. The agent manages the
* user keys and identities, and is responsible to sign and verify messages.
*/
agent: Web5Agent;
/** Exposed instance to the DID APIs, allow users to create and resolve DIDs */
did: DidApi;
/** Exposed instance to the DWN APIs, allow users to read/write records */
dwn: DwnApi;
/** Exposed instance to the VC APIs, allow users to issue, present and verify VCs */
vc: VcApi;
/** The DID of the tenant under which DID operations are being performed. */
private connectedDid: string;
constructor({ agent, connectedDid }: Web5Params) {
this.agent = agent;
this.connectedDid = connectedDid;
this.did = new DidApi({ agent, connectedDid });
this.dwn = new DwnApi({ agent, connectedDid });
this.vc = new VcApi({ agent, connectedDid });
}
/**
* Connects to a {@link Web5Agent}. Defaults to creating a local {@link Web5UserAgent} if one
* isn't provided.
*
* @param options - Optional overrides that can be provided when calling {@link Web5.connect}.
* @returns A promise that resolves to a {@link Web5} instance and the connected DID.
*/
static async connect({
agent, agentVault, connectedDid, password, recoveryPhrase, sync, techPreview
}: Web5ConnectOptions = {}): Promise<Web5ConnectResult> {
if (agent === undefined) {
// A custom Web5Agent implementation was not specified, so use default managed user agent.
const userAgent = await Web5UserAgent.create({ agentVault });
agent = userAgent;
// Warn the developer and application user of the security risks of using a static password.
if (password === undefined) {
password = 'insecure-static-phrase';
console.warn(
'%cSECURITY WARNING:%c ' +
'You have not set a password, which defaults to a static, guessable value. ' +
'This significantly compromises the security of your data. ' +
'Please configure a secure, unique password.',
'font-weight: bold; color: red;',
'font-weight: normal; color: inherit;'
);
}
// Initialize, if necessary, and start the agent.
if (await userAgent.firstLaunch()) {
recoveryPhrase = await userAgent.initialize({ password, recoveryPhrase });
}
await userAgent.start({ password });
// TODO: Replace stubbed connection attempt once Connect Protocol has been implemented.
// Attempt to Connect to localhost agent or via Connect Server.
// userAgent.connect();
const notConnected = true;
if (/* !userAgent.isConnected() */ notConnected) {
// Connect attempt failed or was rejected so fallback to local user agent.
let identity: BearerIdentity;
// Query the Agent's DWN tenant for identity records.
const identities = await userAgent.identity.list();
// If an existing identity is not found found, create a new one.
const existingIdentityCount = identities.length;
if (existingIdentityCount === 0) {
// Use the specified DWN endpoints or get default tech preview hosted nodes.
const serviceEndpointNodes = techPreview?.dwnEndpoints ?? await getTechPreviewDwnEndpoints();
// Generate a new Identity for the end-user.
identity = await userAgent.identity.create({
didMethod : 'dht',
metadata : { name: 'Default' },
didOptions : {
services: [
{
id : 'dwn',
type : 'DecentralizedWebNode',
serviceEndpoint : serviceEndpointNodes,
enc : '#enc',
sig : '#sig',
}
],
verificationMethods: [
{
algorithm : 'Ed25519',
id : 'sig',
purposes : ['assertionMethod', 'authentication']
},
{
algorithm : 'secp256k1',
id : 'enc',
purposes : ['keyAgreement']
}
]
}
});
// The User Agent will manage the Identity, which ensures it will be available on future
// sessions.
await userAgent.identity.manage({ portableIdentity: await identity.export() });
} else if (existingIdentityCount === 1) {
// An existing identity was found in the User Agent's tenant.
identity = identities[0];
} else {
throw new Error(`connect() failed due to unexpected state: Expected 1 but found ${existingIdentityCount} stored identities.`);
}
// Set the stored identity as the connected DID.
connectedDid = identity.did.uri;
}
// Enable sync, unless explicitly disabled.
if (sync !== 'off') {
// First, register the user identity for sync.
await userAgent.sync.registerIdentity({ did: connectedDid });
// Enable sync using the specified interval or default.
sync ??= '2m';
userAgent.sync.startSync({ interval: sync })
.catch((error: any) => {
console.error(`Sync failed: ${error}`);
});
}
}
const web5 = new Web5({ agent, connectedDid });
return { web5, did: connectedDid, recoveryPhrase };
}
}