From SPFx Project Code to Published NPM Package: Hard Lessons from Shipping @bdking71/spsignature
4,587 words, 24 minutes read time.
Why Moving SPFx Code to NPM Is Harder Than It Looks
Internal project code and library code are not the same species. They look alike in the editor. They compile with the same toolchain. They pass the same lint rules. But the moment code leaves a private SPFx solution and becomes a versioned package on the public registry, the rules change. Assumptions that were safe inside one project become defects when strangers depend on them. Shortcuts that saved an afternoon in the original build become tickets, regressions, and support burdens once the package is installed by someone we will never meet.
I learned this the hard way pulling a digital signature workflow out of an SPFx project and publishing it as @bdking71/spsignature. The original code worked. It had run in production. It handled real signatures on real records. None of that mattered when I pulled the package into a second SPFx project and watched it fail. Then fail again. Then fail in a different way after I patched the first failure and published a new version. The publish-install-test loop is slow, and slow feedback loops break patience before they break code. I got to the point where I was editing files directly inside node_modules in the consuming project just to see whether a fix would hold before I bothered pushing another version to the registry. That is the moment the lesson landed. The code was not broken. The code was private. Private code is allowed to hide things. Library code is not, and every assumption I had never written down was now a defect somebody else’s project had to absorb.
This post walks through four lessons that surfaced during that extraction. The first is that context is a contract, not accidental coupling. The second is that idempotent provisioning is the entry fee for anything a consumer installs. The third is that defensive parsing and cryptographic discipline stop being internal choices and become public surface the moment the package ships. The fourth is that scope, storage, and UI boundaries have to be decided honestly, because every export, every global, and every storage key becomes a promise. I will show the actual code from the package as evidence, because the lessons only matter if they hold up under real files rather than sanitized examples. The stack assumed throughout is SPFx targeting SharePoint Online, PnPjs v3 or v4, TypeScript, and React where UI components are involved.
Context Is a Contract, Not an Accident
The first instinct when refactoring SPFx code into a library is to strip out the WebPartContext dependency. It looks like coupling. It looks like the kind of ambient global that a clean library should not require. I fought that instinct for a while, and I was wrong to fight it. The context is not accidental. It is the authentication surface for every call the package needs to make against the tenant. Removing it would not have made the library cleaner. It would have made the library dishonest, because the work still has to happen and the credentials still have to come from somewhere.
The package writes to a SharePoint list called PendingVerifications. It does not send email. It does not send Teams messages. It does not integrate with Microsoft Graph directly for delivery. That is a deliberate scope decision. The library queues a verification request as a list item, and a Power Automate flow bound to that list handles the actual dispatch to email or Teams. The flow is owned by the tenant, not by the package. That boundary is what makes the package small enough to reason about and flexible enough to fit different notification patterns without shipping a dozen configuration knobs. The context is required because PnPjs needs it to authenticate the list write on behalf of the current user, and that write is the entire mechanism by which the outside world learns a passcode was requested.
Here is what that dependency looks like in the OtpService.ts module. The signature is explicit. The consumer has to hand the context in. Nothing is ambient.
export async function dispatchOtpPasscode( context: WebPartContext, request: IOtpDispatchRequest, listTitle: string = DEFAULT_OTP_LIST_NAME ): Promise<IOtpDispatchResult> { try { if (!context) { throw new Error("Execution restricted: Valid WebPartContext is required."); } if (!request || !request.title?.trim()) { throw new Error("Invalid request: Signer/Title identifier is required."); } if (!request.passcode?.trim()) { throw new Error("Invalid request: Passcode must be provided."); } const sp: SPFI = spfi().using(SPFx(context)); const addedItem = await sp.web.lists.getByTitle(listTitle).items.add({ Title: request.title.trim(), Passcode: request.passcode.trim(), Channel: request.channel || "email", });
Notice that the function fails loudly and immediately if context is missing. It does not try to reach for a fallback. It does not try to construct its own authentication path. It refuses to run. That refusal is the contract. The consumer knows before the first list item is written that the package will not silently degrade if the context is absent.
The Two Obligations the Package Imposes on Its Consumer
Publishing this package created two responsibilities that the tenant has to accept. The first is that a valid WebPartContext has to be supplied at runtime, which means the package is only usable inside SPFx solutions or contexts that can construct an equivalent PnPjs authentication surface. The second is that the tenant has to own the Power Automate flow that watches the PendingVerifications list and dispatches the actual message. Neither obligation is hidden. Both are documented as part of the package contract. That is the difference between a leaky abstraction and an explicit boundary. A leaky abstraction pretends the work is not happening. An explicit boundary tells the consumer exactly which half of the work belongs to them.
Idempotent Provisioning Is the Price of Admission
Inside the original SPFx project, the PendingVerifications list already existed. Somebody had created it once, configured the columns, set the item-level permissions, and moved on. The signing code never had to think about provisioning. It just wrote items. That is a luxury private code can afford. Library code cannot. When a package is installed into a fresh tenant, or reinstalled after a partial failure, or upgraded across a schema change, the setup code has to run without breaking anything that already exists. It has to be safe to call once. It has to be safe to call a hundred times. It has to converge on the correct state regardless of what state it started from.
That discipline is what ensurePendingVerificationsList enforces. The function checks whether the list exists before creating it. It checks whether each field exists before adding it. It applies the item-level security settings on every run, because those settings might have drifted or been overridden by an admin. Nothing in the function assumes a clean slate, and nothing in the function fails if the slate is already dirty in the expected way. That is what idempotent means in practice. It is not a buzzword. It is the promise that installing the package twice will not corrupt what installing it once produced.
export const ensurePendingVerificationsList = async ( props: IEnsurePendingVerificationsListProps ): Promise<void> => { if (!props.context) { throw new Error("Execution restricted: Valid WebPartContext is required."); } const listTitle = props.listTitle || DEFAULT_VERIFICATION_LIST_NAME; const listDescription = props.listDescription || DEFAULT_VERIFICATION_LIST_DESC; const sp: SPFI = spfi().using(SPFx(props.context)); let list: IList = sp.web.lists.getByTitle(listTitle); try { await list(); } catch { await sp.web.lists.add(listTitle, listDescription, 100, true); list = sp.web.lists.getByTitle(listTitle); } const ensureField = async ( fieldName: string, createFieldFn: () => Promise<void> ): Promise<void> => { try { await list.fields.getByInternalNameOrTitle(fieldName)(); } catch { await createFieldFn(); } }; await ensureField("Passcode", async () => { await list.fields.addText("Passcode", { Required: true, Description: "Temporary 5-digit signature verification passcode.", }); }); await ensureField("Channel", async () => { await list.fields.addChoice("Channel", { Choices: ["email", "teams", "both"], Required: true, FillInChoice: false, Description: "Target notification delivery route (email, teams, or both).", }); const channelField = list.fields.getByInternalNameOrTitle("Channel"); await channelField.update({ DefaultValue: "email" }); }); await list.update({ ReadSecurity: 2, WriteSecurity: 2, } as Record<string, unknown>); };
The item-level security settings deserve their own attention. ReadSecurity: 2 means a user can only read items they created. WriteSecurity: 2 means a user can only edit items they created. Applied to a list of one-time passcodes, that setting prevents any authenticated user from browsing to the list and reading someone else’s active code. If the package left that configuration to the tenant admin, most tenants would forget it. Most breaches happen at exactly that kind of forgotten default. Enforcing it on every provisioning run is not paranoia. It is the recognition that a list holding verification codes is a small target with a large blast radius if it goes wrong.
Why Setup Code Must Assume the Worst
The ensureField helper is a small pattern with a large payoff. It checks first, creates only on failure, and never assumes the field is in the expected state. That pattern is what makes the function safe under partial failure. If the list exists but the Channel field is missing because a previous run crashed halfway through, the next call fixes it. If everything exists, the next call does nothing except reassert the security settings. There is no cleanup path, no migration script, no dependency on the tenant admin remembering the install order. The function converges. That is what setup code has to do when we cannot see the environment it will land in.
Defensive Parsing and Cryptographic Discipline Become Public Contracts
The response shape returned by PnPjs when adding a list item has changed across versions. In some builds the added item exposes data.Id. In others it exposes data.ID. In others still the ID is available directly on the returned object. When code lives inside a single project pinned to a single PnPjs version, we can write against whichever shape we happen to observe. When code becomes a library and consumers install it alongside their own PnPjs version, we cannot. The extraction path has to survive the drift. That is why dispatchOtpPasscode tries five different shapes before giving up.
let itemId: number | undefined; if (addedItem?.data?.Id) { itemId = addedItem.data.Id; } else if (addedItem?.data?.ID) { itemId = addedItem.data.ID; } else if (debugItem?.Id) { itemId = debugItem.Id as number; } else if (debugItem?.ID) { itemId = debugItem.ID as number; } else if (debugItem?.id) { itemId = debugItem.id as number; } if (!itemId || typeof itemId !== "number" || itemId <= 0) { throw new Error( `Item created but ID extraction failed. Got: ${JSON.stringify(itemId)}` ); }
That block is not elegant. It is honest. Every fallback path exists because someone somewhere will run this code against a version of PnPjs where the previous path returns undefined. The final validation refuses to accept anything that is not a positive integer, because a corrupted ID is worse than no ID at all. If extraction fails, the function throws with the actual value it received, which is exactly the diagnostic a consumer needs when debugging against their own tenant configuration.
Cryptographic discipline follows the same pattern. Inside a private project, we can get away with Math.random for a passcode. Nobody is going to audit it. Once the code ships, Math.random is a defect. It is not cryptographically secure. It is not appropriate for anything that gates access. The package uses window.crypto.getRandomValues to produce the 5-digit code, which pulls from the platform’s secure random source and produces values that cannot be predicted from timing or seed observation. The passcode range is small by design, because it is a one-time code paired with an out-of-band delivery channel and a short lifetime, but the entropy source still has to be right.
function generateFiveDigitPasscode(): string { const array = new Uint32Array(1); window.crypto.getRandomValues(array); const code = (array[0] % (PASSCODE_MAX - PASSCODE_MIN + 1)) + PASSCODE_MIN; return code.toString(); }
The audit envelope carries the same discipline into the signing ceremony. The envelope is a JSON object containing the payload, the signer, the timestamp, and the item ID. It is hashed with SHA-256. The hash is what gets persisted. The original payload can be reconstructed and re-hashed later to prove the record has not been altered. Two subtle decisions make that verification actually work. The timestamp is truncated to second precision, because millisecond drift between two computations of the same envelope would produce a different hash and destroy the verification path. The payload is deep-sorted before hashing, because JavaScript object key ordering is not guaranteed across serializations, and an unsorted hash would fail verification whenever a consumer reconstructed the payload in a different key order.
const deepSortedPayload = deepSortPayload(payload) as Record<string, unknown>; const auditEnvelope: AuditEnvelopeRecord = { payload: deepSortedPayload, signer: sanitizedSigner, timestamp: signatureTimestamp, itemID: itemID, }; const auditEnvelopeJson: string = JSON.stringify(auditEnvelope); const encoder: TextEncoder = new TextEncoder(); const encodedBytes: Uint8Array = encoder.encode(auditEnvelopeJson); const hashBuffer: ArrayBuffer = await window.crypto.subtle.digest( "SHA-256", encodedBytes.buffer as ArrayBuffer );
Including the itemID in the envelope binds the signature to a specific record. Without that binding, a signature captured against one record could be replayed against another. The item ID makes each hash unique to the transaction it was generated for. That is a security decision that has to be right in the first published version, because changing it later would invalidate every signature already produced by earlier releases.
When the Storage Layer Silently Rewrites the Payload
The most expensive lesson from this package had nothing to do with the hash function. SHA-256 is deterministic. The Web Crypto API is deterministic. The deep-sort logic is deterministic. All of that machinery worked correctly the first time. What did not work was the assumption that SharePoint would give me back exactly what I handed it.
An earlier version of the workflow signed payloads that included a multi-line text field. Everything validated on the first signature. When a second user signed the same record, the second signature validated. The first one no longer did. When a third user signed, only the third signature validated. Every prior signature failed verification. The cryptographic code was correct. The library was wrong to trust the storage layer.
SharePoint multi-line text fields do not store content byte-for-byte. Depending on the field configuration, the platform can normalize line endings, wrap content in HTML block elements, re-encode entities, and adjust whitespace. That transformation happens silently between the write and the next read. A payload hashed at signing time against the original string will not match the same field read back later, because the string has been rewritten by the storage layer. The reason only the most recent signature validated was that every new write reset the field to a freshly normalized form, and each prior signature had been hashed against a version of the content that no longer existed anywhere.
The fix is architectural, not cryptographic. The payload that gets hashed has to be the payload the library controls, not the payload the storage layer might rewrite. That means canonicalizing content before hashing, avoiding field types that the platform is allowed to transform, or storing the signed payload snapshot alongside the hash so verification never has to trust the storage layer to preserve the original bytes. Any of those approaches works. Trusting the storage layer to be a faithful mirror does not. That lesson would have stayed invisible inside a private project where the same code path always wrote and read the same field in the same session. It only surfaced when the package was installed somewhere else and used against real records that were signed by multiple people over time.
What Changes When Security Code Becomes a Dependency
Inside a private project, security decisions are internal. We can revisit them, refactor them, tighten them, and nobody outside the team feels the impact. Once security code ships as a dependency, every choice becomes part of the public contract. The passcode length, the hash algorithm, the timestamp precision, the payload canonicalization, the signer sanitization rules, and the envelope structure all become promises. Changing any of them is a breaking change. That is why the discipline has to be applied before the first published version. Retrofitting cryptographic correctness into an installed package is a support incident that no author wants to run.
Scope, Storage, and the UI Boundary Problem
The hardest lessons in this extraction had nothing to do with cryptography or provisioning. They had to do with scope. Every export is a promise. Every global is a compromise. Every storage key is a forward-compatibility problem waiting to happen. The signing ceremony uses a module-level singleton to prevent two modals from opening at the same time. That singleton is not clean. It would be cleaner to route through a proper state manager or a React context. But the package supports consumers who are not using React for their invoking surface, and the modal is built with raw DOM for exactly that reason. The singleton is the honest compromise that lets a single package serve both React and non-React callers.
let activeModalInstance: AbortController | null = null; // ... if (activeModalInstance !== null) { return undefined; } const abortController = new AbortController(); activeModalInstance = abortController;
Storage is another place where library code has to think longer than project code. Cached signatures are persisted to localStorage under a versioned key. The _v1 suffix is not decorative. It is the mechanism by which a future breaking change to the cache format can ship without corrupting caches from earlier versions. When the format changes, the key becomes _v2, the old key can be cleaned up on read, and consumers upgrade without a support ticket. That kind of forward compatibility costs nothing to add on the first release and is nearly impossible to add retroactively.
const STORAGE_KEY = "secure_audit_cached_signature_v1";
The LZW compression used for cached signatures is bounded by a hard dictionary ceiling. When the ceiling is hit, the function throws with a clear error rather than silently corrupting the output. That is a small decision with real consequences. Silent corruption in a cached signature would produce a mismatched hash on the next verification, and the failure would be nearly impossible to diagnose because the corruption would be invisible until the verification path ran. An explicit throw at the moment of failure gives the consumer a diagnostic they can act on.
if (dictionarySize >= LZW_DICT_MAX) { throw new Error( `LZW dictionary overflow: input is too large to compress (${dictionarySize} entries). ` + `Consider using Base64 encoding instead.` ); }
The UI boundary is the messiest part of the package and the most instructive. The signing modal in TransactionSigner.ts is built entirely with raw DOM manipulation. The display component in SignatureDisplay.tsx is a proper React functional component. That inconsistency looks like a defect. It is actually a scope decision. The modal has to run inside contexts where React may not be available or may not be the caller’s preferred framework, so it uses the lowest common denominator. The display component is designed to be embedded in React-based reports and dashboards, where returning a React.FC is the natural surface. Forcing both into the same framework would have made one of them worse to satisfy an aesthetic consistency that no consumer would actually benefit from.
export const SignatureDisplay: React.FC<ISignatureDisplayProps> = ({ auditRecord, isValid, className, style, }) => { const signatureImageSrc = auditRecord.signatureData; const formattedDateTime = formatLocalDateTime(auditRecord.signatureTimestamp); const truncatedHash = truncateHash(auditRecord.signatureHash); const validationStatusColor = isValid ? "#107c10" : "#d13438"; const validationStatusLabel = isValid ? "Signature is Valid" : "Signature is Invalid"; return ( <div className={className} style={{ /* ... */ }}> {/* signature image, signer, status, timestamp, hash */} </div> ); };
The public API surface is the last piece of the scope discipline. Not everything in the package is exported. generateSecureAuditRecordInternal, lzwCompress, lzwDecompress, deepSortPayload, and sanitizeSigner all stay private. They are implementation details. Exporting them would freeze their signatures as part of the package contract and prevent future refactoring. What gets exported is what consumers need: promptAndGenerateSecureAudit to run the ceremony, verifySecureAuditRecord to check a persisted signature, getReportableSignature to decompress a cached image for display, dispatchOtpPasscode to write the OTP record, ensurePendingVerificationsList to provision the storage, and SignatureDisplay to render the result. Everything else is behind the wall on purpose.
The Decision I Would Reverse Today
Not every scope decision in this package aged well. The itemID field lives at the top level of the audit envelope, alongside the payload, the signer, and the timestamp. It is part of the hash. That made sense in the original SPFx project, where every signature was tied to a SharePoint list item and the item ID was the natural anchor. It stopped making sense the moment the package became something other consumers could install.
By pulling itemID out of the payload and giving it its own slot in the envelope, the library made a prescriptive claim about what every signature must be bound to. Consumers who want to sign something that is not a SharePoint list item — a document, a transaction ID from another system, a composite key, or nothing at all — are forced to fabricate a value or accept a meaningless field in a security-relevant position. That is the library imposing its original use case on every future one. It is the exact mistake the rest of the package tries to avoid.
The correct design is to let the consumer decide what belongs in the hash. If binding to a SharePoint item matters, the consumer puts the item ID in the payload. If binding to something else matters, the consumer puts that in the payload. If an unbound signature is acceptable, the consumer leaves it out. The library’s job is to hash whatever payload it is given, not to dictate what fields that payload must contain. That change is a breaking one, which is the cost of getting the shape wrong in the first published version. It is also the honest fix, and it will land in a future major release. The lesson is not that the original decision was careless. It is that the original decision was made from inside the original use case, and the abstraction inherited assumptions that only became visible once the package had to serve consumers whose use cases did not match mine.
Every Storage Key, Global, and Export Is a Promise
The habit that private code lets us skip is the habit of treating every observable behavior as a commitment. Inside a single project, we can rename an internal function on Tuesday and adjust the two callers on Wednesday. Inside a published package, a rename is a breaking change and the callers are strangers we cannot warn. Every storage key we write, every global we create, every export we ship, and every response shape we produce becomes something a downstream project depends on. The discipline is not to minimize these commitments for aesthetic reasons. The discipline is to make each one deliberately, name it clearly, version it where possible, and be honest about what it will cost to change later.
What Shipping This Package Taught Me About the Line Between Project Code and Library Code
The lessons from this extraction all reduce to one thing. Private code is allowed to be quiet about its assumptions. Library code has to declare every one of them out loud. Context is not accidental coupling. It is a documented dependency that draws a line between what the package does and what the tenant owns. Provisioning is not a one-time setup script. It is an idempotent contract that has to hold across fresh installs, reinstalls, partial failures, and schema drift. Defensive parsing and cryptographic discipline are not internal choices. They are public surface the moment the package ships, and every decision made in the first release becomes a promise that costs real work to break. And when the storage layer is allowed to rewrite the payload between the write and the read, the cryptography we trusted stops meaning what we thought it meant.
Scope is where most of the pain lives. Every export is a commitment. Every global is a compromise that has to be defensible in prose. Every storage key is a forward-compatibility decision that has to be made before the first user installs the package. The inconsistency between the raw-DOM modal and the React display component is not sloppiness. It is a scope decision that survives contact with real consumers who do not all live inside the same framework. The private helpers that stayed private did so because their signatures are not stable enough to promise. That kind of restraint is easy to talk about and hard to enforce, because there is always pressure to export one more thing. And some scope decisions — like putting the item ID outside the payload — only reveal themselves as mistakes after the package has been in the wild long enough to serve a use case the original author did not anticipate.
None of us are above the drift that produces leaky libraries. I have watched myself skip validation because the private version worked. I have watched myself want to hide a compromise instead of documenting it. I have watched myself edit files inside node_modules at midnight because publishing another patch version felt like more failure than I wanted to admit in public. I have watched myself defend an API decision longer than I should have, because admitting it was wrong meant admitting the next release had to break. The discipline required to ship a small package well is the same discipline required to ship anything well. It is the discipline of writing down the contract, honoring it, and refusing to let convenience override the promises the code has already made. The tools do not enforce that for us. The registry does not enforce that for us. The consumers cannot see the compromises until the compromises break something they depend on. The only line of defense is the author’s willingness to treat published code as different in kind from private code, and to hold that line every time the temptation to cut a corner shows up in the diff. This package is not finished, and I am not finished learning from it. Every release will teach me something I did not know I had assumed, and every consumer who installs it will find a corner of the design I did not think hard enough about. That is the nature of shipping code into other people’s hands. The lessons in this post are the ones I can see today. The ones I cannot see yet are already waiting in the next issue somebody files.
Call to Action
If this blog caught your attention, don’t just scroll past. Join the community—men sharing skills, stories, and experiences. Subscribe for more posts like this, drop a comment about your projects or lessons learned, or reach out and tell me what you’re building or experimenting with. Let’s grow together.
SUPPORTSUBSCRIBECONTACT ME D. Bryan King
Sources
Disclaimer:
The views and opinions expressed in this post are solely those of the author. The information provided is based on personal research, experience, and understanding of the subject matter at the time of writing. Readers should consult relevant experts or authorities for specific guidance related to their unique situations.
Related Posts
Rate this:
#2FA #AbortController #APIContracts #auditEnvelope #auditTrail #browserCryptography #canonicalJSON #canvasDownscaling #clientSideWebParts #cloudArchitecture #codeRefactoring #cryptographicHashing #defensiveProgramming #dependencyInjection #developerLessons #digitalAudit #DigitalSignatures #electronicSignatures #emailNotifications #EnterpriseDevelopment #enterpriseSoftwareDevelopment #frontendArchitecture #HTMLCanvasDrawing #idempotentProvisioning #itemLevelPermissions #JavaScriptLibraries #JavaScriptObjectNotation #JSONDeepSort #libraryDesign #listSchemaManagement #localStorageCaching #LZWCompression #Microsoft365 #MicrosoftDeveloper #MicrosoftGraph #MicrosoftTeamsIntegration #mobileSignatureCapture #modernSharepoint #NISTCryptoStandards #nodeModules #npmPackage #npmPackagePublishing #npmRegistry #Office365Development #OTPService #packageDeployment #payloadVerification #PnPjs #PnPjsV3 #PnPjsV4 #PowerAutomate #PowerPlatformIntegration #publicAPIDesign #randomNumberGeneration #React #ReadSecurity #scopedNpmPackages #secureCoding #secureDigitalSigning #semanticVersioning #SHA256 #SharePointAuditRecord #SharePointDevelopment #SharePointFieldProvisioning #SharePointFramework #SharePointListSecurity #SharePointOnline #signatureCapture #signatureVerification #softwareArchitecture #softwareCraftsmanship #softwareEngineering #SPFx #SPFxWebParts #SubtleCrypto #tamperEvidentRecords #technicalFieldNotes #touchEventDrawing #TwoFactorAuthentication #TypeScript #TypeScriptDeclarationFiles #WebCryptoAPI #WebDevelopment #WebPartContext #WriteSecurity