diff --git a/.agents/skills/expert-typescript-programmer/SKILL.md b/.agents/skills/expert-typescript-programmer/SKILL.md new file mode 100644 index 00000000..773ee80e --- /dev/null +++ b/.agents/skills/expert-typescript-programmer/SKILL.md @@ -0,0 +1,52 @@ +--- +name: expert-typescript-programmer +description: Use when designing, implementing, refactoring, or reviewing TypeScript features and APIs that should be robust, maintainable, type-safe, and efficient. Applies to TypeScript source code, tsconfig choices, declaration files, library/package APIs, large-codebase performance, and agent-written feature work. +--- + +# Expert TypeScript Programmer + +Use this skill before making meaningful TypeScript changes. It distills official TypeScript Handbook, TSConfig, declaration-file, and TypeScript wiki performance guidance into an agent workflow. + +## Operating Principles + +1. Read the local project first: package scripts, `tsconfig*.json`, framework conventions, module format, lint rules, and nearby code. +2. Preserve the runtime contract. TypeScript types describe JavaScript behavior; they do not add runtime checks. +3. Prefer sound, narrow types over broad escape hatches. Treat `any`, assertions, non-null assertions, and `@ts-ignore` as temporary or boundary-only tools. +4. Model states explicitly. Use unions, discriminants, guards, and exhaustive checks instead of optional fields that imply invalid combinations. +5. Let inference work inside implementations, but annotate exported functions, public APIs, callbacks, and complex return types when that improves readability, stability, or compiler performance. +6. Keep types easy for humans and the compiler: name complex types, avoid huge unions, prefer interface extension for object composition, and split large projects deliberately. +7. Verify with the repo's own checks: typecheck, tests, lint/build, or the closest available script. If no check exists, say so. + +## Reference Loading + +Load only what the task needs: + +- [references/robust-code.md](references/robust-code.md): everyday coding practices, narrowing, object types, generics, assertions, declaration/API design. +- [references/tsconfig.md](references/tsconfig.md): strictness and configuration flags for safer projects. +- [references/performance.md](references/performance.md): compile/editor performance, easy-to-compile type design, project references, troubleshooting. +- [references/sources.md](references/sources.md): official TypeScript sources used to build this skill. + +## Feature Workflow + +1. Identify boundary types: input data, external APIs, persisted values, public exports, package entrypoints, callbacks, and error paths. +2. Choose representation: + - Use discriminated unions for variants with different fields. + - Use `unknown` at untrusted boundaries, then narrow. + - Use `object`/specific object shapes instead of boxed primitives or broad `Object`. + - Use `readonly` and immutable data shapes where callers should not mutate values. +3. Implement runtime validation or guards where values come from outside the type system. +4. Keep type machinery proportional. Reach for conditional, mapped, indexed-access, and template-literal types when they simplify public usage; avoid cleverness that obscures behavior. +5. Review the diff for type escapes and configuration drift before finishing. + +## Review Checklist + +- `strict` expectations are respected; new code does not rely on implicit `any` or unchecked nullish values. +- Assertions are justified by nearby runtime facts or replaced with guards. +- Index access handles possible `undefined` when keys are not guaranteed. +- Optional properties mean absence; if `undefined` is an allowed value, the type says so. +- Overloads are ordered specific-to-general, or replaced by optional parameters/unions when return behavior permits. +- Exported APIs have stable names and annotations where inference would leak complex anonymous types. +- Large object type composition uses `interface extends` where appropriate instead of deep intersections. +- Large unions, distributive conditional types, and generated-looking type expansions are avoided or named. +- Module settings match the runtime/bundler, especially Node ESM/CJS behavior. +- Typecheck/test commands were run or a limitation is reported. diff --git a/.agents/skills/expert-typescript-programmer/agents/openai.yaml b/.agents/skills/expert-typescript-programmer/agents/openai.yaml new file mode 100644 index 00000000..bc7aaeb0 --- /dev/null +++ b/.agents/skills/expert-typescript-programmer/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Expert TypeScript Programmer" + short_description: "Robust, efficient TypeScript feature work" + default_prompt: "Use $expert-typescript-programmer to implement this TypeScript feature with robust types and efficient compiler-friendly design." +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/expert-typescript-programmer/references/performance.md b/.agents/skills/expert-typescript-programmer/references/performance.md new file mode 100644 index 00000000..1a778ea7 --- /dev/null +++ b/.agents/skills/expert-typescript-programmer/references/performance.md @@ -0,0 +1,62 @@ +# TypeScript Performance + +Official source: TypeScript wiki Performance page maintained in the Microsoft TypeScript repository, plus TSConfig pages for related options. + +## Easy-to-Compile Code + +- Prefer `interface extends` for composing object types instead of large intersection aliases. Interfaces create a flatter object type, detect conflicts, display better, and allow relationship caching. +- Add explicit return annotations in hot or exported areas if inference creates large anonymous types or expensive declaration emit. +- Name complex conditional/mapped types so the compiler can cache and display them more effectively. +- Avoid very large unions where a base interface plus subtypes would model the same domain. Large unions can require repeated or pairwise comparisons. +- Do not inline complex conditional return types inside frequently used call signatures. Extract them: + +```ts +type FooResult = + U extends TypeA ? ProcessTypeA : + U extends TypeB ? ProcessTypeB : + U; + +interface SomeType { + foo(value: U): FooResult; +} +``` + +## Project Structure + +- Split non-trivial codebases into projects with project references when scale hurts editor or build performance. +- In monorepos, mirror package dependencies with project references where practical. +- Aim for projects that are meaningfully sized and edited together. Too few projects can overload the editor; too many can duplicate overhead. +- Separate tests from product code when it prevents product projects from loading test-only dependencies or globals. + +## tsconfig Performance + +- Keep `include` narrow and source-focused. +- Avoid source directories that contain `node_modules`, build outputs, generated artifacts, or other projects' source. +- Set `types: []` or a specific `types` list when automatic global type inclusion is unnecessary or conflicting. +- Use `incremental` for repeated builds. +- Consider `skipLibCheck` for faster builds, but recognize it can hide declaration-file conflicts or misconfiguration. Prefer fixing dependency/type duplication when feasible. +- Build with `strictFunctionTypes` (usually via `strict`) for faster variance checks. + +## Toolchain Pattern + +- If transpilation is handled by a bundler or another compiler, run type-checking concurrently or separately where the repo supports it. +- For isolated emit pipelines, make sure code is compatible with single-file transpilers (`isolatedModules`/related settings as established by the repo). + +## Troubleshooting Slow TypeScript + +Use official diagnostics before guessing: + +- `tsc --extendedDiagnostics`: time and memory summary. +- `tsc --showConfig`: inspect the final config. +- `tsc --listFilesOnly`: see which files are in the program. +- `tsc --explainFiles`: understand why files are included. +- `tsc --traceResolution`: debug module/type resolution. +- Run `tsc` alone to separate TypeScript cost from bundler/test-runner cost. +- Disable editor TypeScript plugins if editor latency does not reproduce in CLI. +- Take a TypeScript performance trace for persistent compiler or editor issues. + +## Agent Heuristics + +- If a generated type is hard for you to read, it is probably hard for the compiler and future maintainers too. +- Prefer boring, named, composable types for feature work. +- Only optimize type-level performance after identifying scale, a hotspot, or a known problematic pattern. diff --git a/.agents/skills/expert-typescript-programmer/references/robust-code.md b/.agents/skills/expert-typescript-programmer/references/robust-code.md new file mode 100644 index 00000000..27c9ac4c --- /dev/null +++ b/.agents/skills/expert-typescript-programmer/references/robust-code.md @@ -0,0 +1,93 @@ +# Robust TypeScript Code + +Official sources: TypeScript Handbook pages for Everyday Types, Narrowing, Object Types, Generics, type manipulation, Modules, and declaration-file Do's and Don'ts. + +## Safety Defaults + +- New TypeScript code should favor strict checking. The Handbook says strictness generally pays for itself over time and gives better checks/tooling. +- Avoid `any` in finished TypeScript. It disables checking for values that use it. Use `unknown` when accepting values whose type is not yet known, then narrow before use. +- Type assertions, including double assertions through `unknown` or `any`, are compile-time only. They do not validate at runtime. Use them only when the runtime fact is guaranteed elsewhere. +- Avoid non-null assertions unless control flow or an invariant truly proves presence. Prefer explicit checks that help both readers and TypeScript. +- Use primitive types `string`, `number`, `boolean`, and `symbol`, not boxed `String`, `Number`, `Boolean`, `Symbol`, or broad `Object`. + +## Inference and Annotations + +- Let TypeScript infer local variables and simple implementation details. +- Add annotations for function parameters, exported/public return types, package boundaries, callbacks, and places where inference produces a complex or unstable type. +- Annotation can be both documentation and a guard against accidentally changing an exported API. + +## Narrowing and Runtime Facts + +- Narrow with normal JavaScript checks: `typeof`, truthiness where appropriate, equality checks, `in`, `instanceof`, assignments, control flow, and user-defined type predicates. +- Remember that narrowing follows runtime behavior. Do not encode a type relationship that runtime code does not actually enforce. +- Use discriminated unions for variants. Give every variant a shared literal property such as `kind`, `type`, or `status`, then switch on that property. +- Use exhaustive `never` checks in switches over closed unions: + +```ts +function assertNever(value: never): never { + throw new Error(`Unexpected value: ${String(value)}`); +} +``` + +## Objects and Data Shapes + +- Prefer named `interface` or `type` declarations for reusable object shapes. +- Use optional properties for properties that may be absent. With stricter config, absence and `undefined` can be distinct. +- Use `readonly` to communicate and enforce that callers should not reassign a property. It is a type-level restriction, not deep runtime immutability. +- Use index signatures only when truly unknown keys are supported. Pair them with safer compiler options when possible. +- Let excess property checks catch misspelled object literal keys. Avoid bypassing them with broad intermediate variables or assertions unless intentional. + +## Generics + +- A generic parameter should carry information from one position to another: input to output, key to value, element to container, etc. Do not introduce unused type parameters. +- Constrain generics when the implementation relies on a capability: + +```ts +function getProperty(obj: T, key: K): T[K] { + return obj[key]; +} +``` + +- Prefer generic defaults when they remove repetitive overloads without hiding important behavior. +- Keep advanced type programming small and named. Conditional, mapped, indexed-access, and template-literal types are powerful, but large anonymous compositions are hard to read and can slow checking. +- For template-literal unions, official docs recommend ahead-of-time generation for large string unions. + +## API and Declaration Design + +- Use `void` for callback return values that are intentionally ignored. +- Do not write overloads that differ only by callback arity; use the maximum arity since callbacks may ignore parameters. +- Order overloads from most specific to most general. +- Prefer optional parameters over overloads that only add trailing parameters, when return type is the same. +- Prefer union parameters over overloads that differ by one argument type and have compatible behavior. +- Publish generated declarations with source packages when a package's own source can generate types. + +## Modules + +- A file with a top-level `import` or `export` is a module; a file without one is a script in global scope. +- Prefer ES module syntax for modern TypeScript unless the project has an established CommonJS/runtime reason. +- Choose compiler module options to match the actual runtime host or bundler. Even with `noEmit`, module settings affect type checking and IntelliSense. + +## Boundary Pattern + +At untrusted boundaries such as JSON, request bodies, storage, environment variables, or `catch` values: + +1. Accept `unknown`. +2. Validate runtime shape. +3. Return a narrowed domain type. +4. Keep the rest of the code free of assertions. + +```ts +interface User { + id: string; + name: string; +} + +function isUser(value: unknown): value is User { + return typeof value === "object" && + value !== null && + "id" in value && + "name" in value && + typeof value.id === "string" && + typeof value.name === "string"; +} +``` diff --git a/.agents/skills/expert-typescript-programmer/references/sources.md b/.agents/skills/expert-typescript-programmer/references/sources.md new file mode 100644 index 00000000..85286af5 --- /dev/null +++ b/.agents/skills/expert-typescript-programmer/references/sources.md @@ -0,0 +1,43 @@ +# Official TypeScript Sources + +This skill is based on official TypeScript documentation and Microsoft TypeScript repository material gathered on 2026-05-12. + +## Handbook + +- Everyday Types: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html +- Narrowing: https://www.typescriptlang.org/docs/handbook/2/narrowing.html +- Object Types: https://www.typescriptlang.org/docs/handbook/2/objects.html +- Generics: https://www.typescriptlang.org/docs/handbook/2/generics.html +- Conditional Types: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html +- Mapped Types: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html +- Template Literal Types: https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html +- Modules: https://www.typescriptlang.org/docs/handbook/2/modules.html +- Modules Theory: https://www.typescriptlang.org/docs/handbook/modules/theory.html +- Project References: https://www.typescriptlang.org/docs/handbook/project-references.html +- Type Inference: https://www.typescriptlang.org/docs/handbook/type-inference.html + +## TSConfig + +- `strict`: https://www.typescriptlang.org/tsconfig/strict.html +- `exactOptionalPropertyTypes`: https://www.typescriptlang.org/tsconfig/exactOptionalPropertyTypes.html +- `noUncheckedIndexedAccess`: https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html +- `useUnknownInCatchVariables`: https://www.typescriptlang.org/tsconfig/useUnknownInCatchVariables.html +- `noImplicitOverride`: https://www.typescriptlang.org/tsconfig/noImplicitOverride.html +- `noPropertyAccessFromIndexSignature`: https://www.typescriptlang.org/tsconfig/noPropertyAccessFromIndexSignature.html +- `verbatimModuleSyntax`: https://www.typescriptlang.org/tsconfig/verbatimModuleSyntax.html +- `moduleResolution`: https://www.typescriptlang.org/tsconfig/moduleResolution.html +- `incremental`: https://www.typescriptlang.org/tsconfig/incremental.html +- `skipLibCheck`: https://www.typescriptlang.org/tsconfig/skipLibCheck.html + +## Declaration Files and APIs + +- Declaration Files Introduction: https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html +- Declaration Files Do's and Don'ts: https://www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html +- Declaration Files Publishing: https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html + +## Microsoft TypeScript Repository Wiki + +- Performance: https://github.com/microsoft/TypeScript/wiki/Performance +- Coding Guidelines: https://github.com/microsoft/TypeScript/wiki/Coding-guidelines + +Note: the TypeScript repository coding guidelines explicitly say they are for contributors to the TypeScript compiler codebase, not prescriptive community guidance. Use them only for naming/style ideas when they match a local repo's conventions. diff --git a/.agents/skills/expert-typescript-programmer/references/tsconfig.md b/.agents/skills/expert-typescript-programmer/references/tsconfig.md new file mode 100644 index 00000000..bb29a29c --- /dev/null +++ b/.agents/skills/expert-typescript-programmer/references/tsconfig.md @@ -0,0 +1,60 @@ +# TypeScript Configuration + +Official sources: TSConfig reference pages for `strict`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `noImplicitOverride`, `noPropertyAccessFromIndexSignature`, `verbatimModuleSyntax`, `moduleResolution`, `incremental`, and the Handbook module/project configuration pages. + +## Recommended Safety Baseline + +For new or actively maintained TypeScript code, prefer: + +```json +{ + "compilerOptions": { + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "useUnknownInCatchVariables": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true + } +} +``` + +Adopt these incrementally in existing codebases if enabling them at once would create noisy churn. + +## Flag Guidance + +- `strict`: enables a family of stronger checks for correctness. Future TypeScript versions may add stricter checks under this umbrella, so upgrades can reveal new errors. +- `noImplicitAny`: part of `strict`; prevents places TypeScript cannot infer from silently becoming `any`. +- `strictNullChecks`: part of `strict`; forces explicit handling of `null` and `undefined`. +- `strictFunctionTypes`: part of `strict`; also enables faster variance-related assignability checks in some cases. +- `exactOptionalPropertyTypes`: treats `prop?: T` as absent-or-`T`, not automatically `T | undefined`. +- `noUncheckedIndexedAccess`: adds `undefined` to values read through keys that are not known to exist. +- `useUnknownInCatchVariables`: catch variables are `unknown`, requiring verification before use as `Error`. +- `noImplicitOverride`: requires `override` when subclass members override base members, catching refactor drift. +- `noPropertyAccessFromIndexSignature`: requires bracket access for fields only known through an index signature, making uncertainty visible. + +## Module and Emit Choices + +- `moduleResolution: "node16"` or `"nodenext"`: use for modern Node projects that need Node's ESM/CJS behavior. +- `moduleResolution: "bundler"`: use for bundler-driven projects where package `"imports"`/`"exports"` are respected and relative import extensions are not required. +- Avoid `classic` module resolution in modern projects. +- `verbatimModuleSyntax`: simplifies import elision by preserving imports/exports without a `type` modifier and dropping type-only imports/exports. Prefer explicit `import type` / `export type` where applicable. +- Choose `module` based on the actual host. TypeScript needs accurate module information even when `noEmit` is true. +- Use `declaration`/`declarationMap` for libraries where consumers need stable public types and source navigation. +- Use `sourceMap`/`inlineSources` according to the repo's debugging/deployment practice. + +## Project Size and File Inclusion + +- Keep `include` focused on source folders. Avoid globs that sweep in build output, dependency folders, generated artifacts, or unrelated packages. +- If adding an `exclude` list, explicitly exclude `node_modules` because custom excludes replace defaults in ways that can surprise monorepos. +- Use the `types` option to limit automatic global `@types` inclusion, especially for test configs or packages with conflicting globals. +- Use project references for non-trivial workspaces, libraries, and monorepos where independent packages/projects should typecheck separately. +- `incremental` stores project graph information in `.tsbuildinfo` files to speed later builds. It is enabled by default with `composite`. + +## Config Review Questions + +- Does this package run in Node, a browser, an edge worker, a bundler, or multiple targets? +- Are emitted modules and resolved modules the same format the runtime expects? +- Are tests, generated files, and build outputs part of the typecheck by accident? +- Are global types intentionally included? +- Are strictness gaps local and justified, or inherited inertia? diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..e0d13187 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,9 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "unpkg-dev" + +[setup] +script = ''' +cd "$CODEX_WORKTREE_PATH" +pnpm install +''' diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..bb68773c --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +CLOUDFLARE_API_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c841e357 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + name: Build and test + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + run_install: false + + - name: Set up Node for tooling + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Set up Bun runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.8 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm run build + + - name: Test packages + run: pnpm -r test + + - name: Install Playwright browser + run: pnpm --filter unpkg-scripts exec playwright install --with-deps chromium + + - name: Test ESM smoke suites + env: + ESM_UNPKG_ORIGIN: http://localhost:3002 + run: | + set -euo pipefail + + (cd packages/unpkg-files && MODE=development bun --port 4000 ./src/server.ts) > /tmp/unpkg-files.log 2>&1 & + files_pid=$! + (cd packages/unpkg-esm && ./node_modules/.bin/wrangler dev --env dev --port 3002 --inspector-port 9232 --ip 127.0.0.1) > /tmp/unpkg-esm.log 2>&1 & + esm_pid=$! + (cd packages/unpkg-www && ./node_modules/.bin/wrangler dev --env dev --port 3000 --inspector-port 9230 --ip 127.0.0.1) > /tmp/unpkg-www.log 2>&1 & + www_pid=$! + + cleanup() { + kill "$www_pid" "$esm_pid" "$files_pid" 2>/dev/null || true + wait "$www_pid" "$esm_pid" "$files_pid" 2>/dev/null || true + } + trap cleanup EXIT + + for url in http://localhost:4000/_health http://localhost:3002/_health http://localhost:3000/_health; do + for attempt in {1..60}; do + if curl --silent --fail "$url" >/dev/null; then + break + fi + + if [ "$attempt" -eq 60 ]; then + echo "Timed out waiting for $url" + echo "::group::unpkg-files log" + cat /tmp/unpkg-files.log || true + echo "::endgroup::" + echo "::group::unpkg-esm log" + cat /tmp/unpkg-esm.log || true + echo "::endgroup::" + echo "::group::unpkg-www log" + cat /tmp/unpkg-www.log || true + echo "::endgroup::" + exit 1 + fi + + sleep 1 + done + done + + pnpm test:esm-compat -- --corpus scripts/esm-compat-corpus.seed.json --skip-baseline --no-restart-local-services --concurrency 4 + pnpm test:esm-browser -- --corpus scripts/esm-compat-corpus.seed.json --origin http://localhost:3002 --run-origin http://localhost:3000 --limit 100 diff --git a/.gitignore b/.gitignore index 207a7016..17eea6d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ dist/ node_modules/ +.reports/ +.env +.env.* +!.env.example # `pnpm deploy` output dist-app/ -dist-www/ \ No newline at end of file +dist-www/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..203efe01 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +## Project Context + +This repository powers UNPKG, a CDN and web app for serving npm package files directly from package tarballs. It includes Cloudflare Workers for public request handling, a Bun origin service for package file access and transformations, and shared tooling for compatibility and release work. + +## Repository Structure + +- This is a pnpm workspace. Root scripts fan out to the packages under `packages/*`. +- `packages/unpkg-www` is the main `unpkg.com` Cloudflare Worker. +- `packages/unpkg-app` is the app/browse Cloudflare Worker. +- `packages/unpkg-esm` is the `esm.unpkg.com` Cloudflare Worker. It owns ESM-specific routing, metadata, raw/build proxying, inline transforms, and esm.sh-compatible request behavior. +- `packages/unpkg-files` is the Bun-based origin file server. Package file reads, tarball handling, and ESM build/transform work live here. +- `packages/unpkg-worker` contains shared Cloudflare Worker utilities used by the worker packages. +- `scripts` contains repo-level maintenance and compatibility runners, including the ESM compatibility, browser smoke, readiness, and corpus generation tools. +- `docs` contains product and implementation specs for larger efforts. +- `pnpm vendor:esm-sh` starts a pinned upstream esm.sh Docker image for local baseline corpus testing. + +## Runtime And Tooling + +- Node.js is used for tooling. The repo requires Node `>=23`. +- Bun is used for runtime and tests. The `unpkg-files` server runs on Bun, and package tests use `bun test`. +- Docker is used to run the pinned local esm.sh baseline for compatibility tests. +- Use pnpm for workspace commands and dependency management. +- Do not assume Node and Bun are interchangeable here: prefer the command already declared in `package.json` scripts. +- Local Cloudflare API tokens belong in `.env.local`, which is gitignored. Wrangler deploy scripts load it through `scripts/with-local-env.sh`; do not commit machine-specific token paths or secret values. + +## Common Commands + +- Install dependencies with `pnpm install`. +- Build everything with `pnpm run build`. +- Run the full repo test suite with `pnpm test`. This runs a build first via `pretest`. +- Run package-specific commands with `pnpm --filter +``` + +When query parameters are needed on a trailing-slash import-map entry, the service should support an import-map-friendly form equivalent to esm.sh's `&flag/` pattern: + +```txt +https://esm.unpkg.com/react-dom@18.3.1&dev/ +``` + +This should be normalized internally to the same cache key as: + +```txt +https://esm.unpkg.com/react-dom@18.3.1/?dev +``` + +## Error Responses + +Errors must be deterministic, JSON by default for API-like requests, and readable in browsers. + +Required status codes: + +- `400` invalid query, incompatible options, unsupported target, invalid semver, invalid alias/deps syntax; +- `404` package, version, subpath, or declaration file not found; +- `415` unsupported source type, such as unsupported `.vue` or `.svelte` when not enabled; +- `422` package found but cannot be transformed for the requested mode; +- `500` unexpected server failure; +- `503` build queued or timed out with retry guidance. + +Deterministic build failures should be cached for a short TTL to avoid repeated expensive rebuild attempts. Expensive builds should run synchronously up to a short timeout; if the timeout is exceeded, the build should continue in the background and return `503` with retry guidance. Duplicate concurrent requests for the same build cache key should share one queued build. + +Error shape: + +```json +{ + "error": { + "code": "UNSUPPORTED_NODE_BUILTIN", + "message": "Package foo imports node:fs, which is not available in browser builds.", + "package": "foo", + "version": "1.2.3", + "subpath": "." + } +} +``` + +## Architecture + +The implementation should preserve a clear boundary between edge request handling and heavier build work. Heavy bundling should run on the `unpkg-files` origin or a sibling Bun service, not inside Cloudflare Workers. + +Recommended components: + +- `packages/unpkg-esm`: route `esm.unpkg.com` traffic at the Cloudflare Worker layer, normalize URLs, parse query parameters, handle redirects, serve cached artifacts, and call the build service on misses. +- `packages/unpkg-www`: continue to serve the main `unpkg.com` website and file CDN surface. +- `packages/unpkg-worker`: share package parsing, npm metadata lookup, package export resolution, import rewriting, cache helpers, and diagnostics. +- `packages/unpkg-files`: continue to provide npm package tarball/file access and add origin build endpoints for CPU-heavy transforms. +- New build service module or package inside or next to `unpkg-files`: encapsulate bundler integration, dependency graph construction, transform options, source maps, declaration metadata, and artifact generation. + +Bundler choice: + +- Try Bun's built-in bundler first because `unpkg-files` runs on Bun. +- Keep the build-service abstraction bundler-agnostic. +- Fall back to esbuild running on Bun when Bun's bundler cannot provide the resolver hooks, transform behavior, source maps, or esm.sh compatibility required by this spec. + +Artifact storage: + +- Persist build artifacts at the origin rather than relying only on Cloudflare cache. +- Use filesystem or volume storage if `unpkg-files` has durable storage available. +- Otherwise use object storage such as R2 keyed by the normalized build cache key. +- Cloudflare should cache artifact responses at the edge, but an edge cache miss should fetch a persisted artifact before rebuilding. + +## Build Cache Key + +The build cache key must include: + +- package name; +- resolved package version; +- resolved subpath; +- target; +- environment mode; +- minification; +- source map mode; +- bundling mode; +- dependency overrides and their resolved versions; +- aliases; +- externals; +- export conditions; +- selected exports; +- JSX mode and import source; +- worker mode; +- transformer version; +- UNPKG ESM service version. + +The key must not include raw semver ranges after resolution except in metadata fields. Build artifacts are keyed by concrete versions and normalized options. + +## Security and Limits + +- Do not execute package code during builds. +- Enforce build timeouts and memory limits. +- Enforce maximum output size and maximum module graph size. +- Enforce a build queue with per-key de-duplication and global concurrency limits. +- Prevent SSRF by only fetching package contents and metadata through approved npm/UNPKG services. +- Sanitize source map paths. +- Source maps should reference public package URLs or stable virtual package paths, never origin filesystem paths. +- Avoid leaking internal build-service URLs in public artifacts. +- Rate limit repeated failed builds and oversized packages. + +## Observability + +Track: + +- request count by package, version, subpath, and option set; +- cache hit/miss at edge and build artifact layers; +- build duration and queue time; +- build failures by diagnostic code; +- top unsupported Node builtins; +- top unsupported source types; +- artifact size before and after minification; +- request count savings for bundled output versus unbundled output. + +## Documentation Requirements + +Public docs must include: + +- URL format and package subpath examples; +- all query parameters and conflicts; +- import map examples; +- React and Preact examples with `?deps` and `?alias`; +- development versus production examples; +- standalone bundle examples; +- TypeScript declaration behavior; +- `?meta` examples with integrity; +- limitations, including no non-npm registries; +- an esm.sh compatibility table that separates supported, deferred, and intentionally unsupported features. + +## Test Plan + +### Unit Tests + +- URL parser and query normalization. +- npm package specifier parsing, including scoped packages and subpaths. +- package `exports` and condition resolution. +- dependency override and alias parsing. +- externalization matching. +- build cache key normalization. +- error response generation. + +### Integration Tests + +Validate browser-compatible output for representative packages: + +- `react` +- `react-dom/client` +- `preact` +- `htm` +- `lit` +- `lodash-es` +- `lodash` +- `d3` +- `date-fns` +- `nanoid` +- `zustand` +- `swr` with `?deps=react@18.3.1` +- a React package aliased to Preact with `?alias=react:preact/compat` +- a TypeScript package source +- a TSX package source +- a package with conditional exports +- a package with Node builtin references +- a package requiring `?external` +- a package using `peerDependencies` + +### Browser Tests + +Use real browser tests to verify: + +- direct module imports execute; +- import maps resolve externalized dependencies; +- worker mode creates and starts a module worker; +- source maps are discoverable; +- type declaration headers are present or absent with `?no-dts`; +- bundled D3-style output materially reduces request count versus unbundled UNPKG `?module`. + +### Compatibility Suite + +Run a recurring suite over the top 100 npm packages by download count. The public beta target is at least 80% successful transform/build coverage, with failures classified by diagnostic code. The final launch threshold should be agreed after the beta produces real package data. + +Record: + +- build success/failure; +- browser execution smoke result where practical; +- output size; +- number of generated module requests; +- unsupported feature diagnostics. + +## Implementation Plan + +The implementation should be built in vertical slices. Each phase should ship tests and a small set of real package fixtures before the next layer is added. + +### Phase 1: Routing, URL Semantics, and Metadata + +Primary files and modules: + +- Add a dedicated `packages/unpkg-esm` Worker with production and staging routes for `esm.unpkg.com` and `esm.unpkg.dev`. +- Add shared URL parsing and query normalization helpers in `packages/unpkg-worker`. +- Reuse existing npm metadata and file helpers from `packages/unpkg-worker`. + +Tasks: + +1. Parse npm package names, scoped package names, semver ranges, dist-tags, and subpaths from `esm.unpkg.com` URLs. +2. Normalize esm.sh-compatible query syntax, including import-map-friendly `&flag/` trailing-slash URLs. +3. Resolve ranges and dist-tags to concrete npm versions. +4. Implement redirects from friendly URLs to versioned artifact URLs. +5. Implement `?meta` with package name, resolved version, subpath, target, export subpaths when known, declaration path when known, and placeholder artifact URLs. +6. Add JSON diagnostics for invalid package specs, invalid query combinations, missing packages, and missing versions. + +Exit criteria: + +- `https://esm.unpkg.com/react`, `https://esm.unpkg.com/react@18`, and `https://esm.unpkg.com/react-dom@18/client?meta` resolve deterministically. +- Unit tests cover scoped packages, subpaths, query normalization, and error shape. + +### Phase 2: Build Service Skeleton + +Primary files and modules: + +- Create a build-service module or package dedicated to ESM artifact generation inside or next to `packages/unpkg-files`. +- Add origin build endpoints to `packages/unpkg-files`. +- Keep the build-service API independent of Cloudflare Worker APIs so the edge worker only sees cacheable artifact and metadata responses. + +Build service API: + +```ts +interface BuildRequest { + packageName: string; + version: string; + subpath: string; + options: NormalizedBuildOptions; +} + +interface BuildResult { + code: string; + map?: string; + headers: Record; + metadata: BuildMetadata; + diagnostics: BuildDiagnostic[]; +} +``` + +Tasks: + +1. Fetch package files through existing UNPKG file services rather than arbitrary network access. +2. Create a deterministic build cache key from the normalized request. +3. Build a simple JavaScript ESM response for packages that already publish browser-compatible ESM. +4. Rewrite bare dependency imports to `esm.unpkg.com` URLs. +5. Persist build artifacts at origin and serve them with bounded, revalidatable cache headers. +6. Start with Bun's built-in bundler where it can satisfy the required resolver hooks, while preserving a clean esbuild fallback path. + +Exit criteria: + +- ESM packages such as `lodash-es`, `nanoid`, and `date-fns` import successfully in a browser. +- Build cache keys are stable across equivalent URLs. + +### Phase 3: Resolver and esm.sh Dependency Controls + +Primary files and modules: + +- Extend `packages/unpkg-worker/src/lib/pkg-exports.ts` or adjacent resolver modules. +- Add dependency graph utilities in the build service. + +Tasks: + +1. Implement the browser condition order and `?conditions`. +2. Implement `?deps` with concrete version resolution and transitive application. +3. Implement `?alias` before dependency resolution. +4. Implement `?external` and `?external=*`, preserving bare specifiers for import maps. +5. Add peer dependency awareness so common framework packages do not duplicate React/Preact. +6. Compare behavior against esm.sh for representative URLs and encode divergences as tests or documented limitations. + +Exit criteria: + +- `swr?deps=react@18.3.1` uses the requested React version. +- `?alias=react:preact/compat&deps=preact@10.25.4` produces Preact-compatible imports. +- `?external=react` leaves React imports bare. + +### Phase 4: Transform Pipeline + +Recommended transformer: + +- Prefer Bun's built-in bundler if it can provide the required behavior. +- Use esbuild on Bun for JavaScript, CommonJS, TypeScript, JSX, TSX, target lowering, minification, source maps, selected exports where Bun cannot meet the compatibility requirements. + +Tasks: + +1. Add CJS-to-ESM transformation. +2. Add `.ts`, `.tsx`, and `.jsx` transforms. +3. Add CSS stylesheet serving and CSS module output. +4. Implement browser `?target`, `?dev`, `?env`, `?min`, and `?sourcemap`. +5. Implement `?keep-names` and `?ignore-annotations`. +6. Implement `?exports` for modules where export selection is safe. +7. Return clear `415` diagnostics for Vue, Svelte, and other unsupported source formats. +8. Return clear `422` diagnostics for transform failures. + +Exit criteria: + +- React, React DOM, Preact, htm, lodash, and TypeScript/TSX fixtures load in browser tests. +- Development builds choose development conditions and replace `process.env.NODE_ENV` appropriately. + +### Phase 5: Bundling Modes + +Tasks: + +1. Implement default smart bundling for package-internal modules. +2. Implement `?bundle=false` and `?no-bundle` as no-bundle modes that emit rewritten ESM imports. +3. Implement `?bundle` as explicit dependency bundling where safe. +4. Implement `?standalone` as single-artifact bundling except peer dependencies, externals, and unsupported modules. +5. Track request-count reduction and output size in metadata and observability. + +Exit criteria: + +- D3-style packages show a large request-count reduction compared with UNPKG `?module`. +- Bundling modes produce deterministic, distinct artifacts. +- Side-effect-sensitive and `import.meta.url`-sensitive packages have compatibility tests or documented limitations. + +### Phase 6: Types, Raw Mode, Metadata, and Integrity + +Tasks: + +1. Implement declaration discovery from `exports[...].types`, `types`, and `typings`. +2. Serve declaration files and emit `X-TypeScript-Types` unless `?no-dts` is present. +3. Implement `?raw` for untransformed package files. +4. Fill out `?meta` with module URL, type URL, dependency versions, peer dependencies, build options, diagnostics, and integrity. +5. Compute SRI-compatible hashes over build artifacts. + +Exit criteria: + +- Type-aware tools can discover declarations through the response header. +- `?meta` is useful for import-map generation and cache/integrity tooling. +- Raw mode is mutually exclusive with transform options and returns useful diagnostics for conflicts. + +### Phase 7: Runtime Targets, Polyfills, and Worker Mode + +Tasks: + +1. Add browser shims for common Node builtins: `process`, `buffer`, `events`, `util`, `path`, and `url`. +2. Return diagnostics for hard Node-only modules such as `fs`, `net`, `tls`, and `child_process` when active browser code imports them. +3. Include polyfill decisions in build metadata. +4. Evaluate `unenv` or an equivalent maintained compatibility layer. +5. Implement `?worker` with a default export `createWorker(options?)`. +6. Verify module worker behavior in browser tests. + +Exit criteria: + +- Packages with common Node builtin references work when browser-compatible shims are sufficient. +- Worker mode can instantiate and message a generated module worker. + +### Phase 8: Deferred esm.sh Compatibility Items + +These items are part of the compatibility roadmap but are not required for the first public package-import release. + +Tasks: + +1. Implement `?target=deno`, `?target=denonext`, and `?target=node` with runtime-appropriate conditions and builtin handling. +2. Implement `https://unpkg.com/run` as the inline helper module. +3. Compile inline `text/babel`, `text/jsx`, `text/ts`, and `text/tsx` scripts through the build service. +4. Cache compiled source by content hash. +5. Respect import maps and JSX runtime configuration. +6. Add browser tests covering React and Preact inline TSX. +7. Implement Vue and Svelte transforms if they become launch requirements. + +Exit criteria: + +- Deno and Node targets produce runtime-appropriate output for representative npm packages. +- A no-build HTML page using inline TSX runs with `https://unpkg.com/run`. +- Import-map runtime selection works for React and Preact. + +### Phase 9: Compatibility Suite, Beta, and Launch + +Tasks: + +1. Build an automated esm.sh compatibility runner that tests equivalent npm URLs on `esm.sh` and `esm.unpkg.com`. +2. Run the top-100 npm compatibility suite and classify failures by diagnostic code. +3. Add public documentation with an esm.sh compatibility table. +4. Launch behind a beta hostname or feature flag. +5. Monitor build failures, cache misses, queue time, artifact size, and popular unsupported features. +6. Promote `esm.unpkg.com` DNS only after compatibility, reliability, and performance thresholds are met. + +Exit criteria: + +- The top-100 compatibility threshold is agreed and met. +- All intentional gaps are documented. +- Rollback is possible by disabling the `esm.unpkg.com` route without affecting `unpkg.com`. + +Implementation notes: + +- The representative runner lives at `scripts/esm-compat-suite.ts` and is exposed as `pnpm test:esm-compat`. +- The public compatibility matrix and beta launch checklist live in `docs/esm-unpkg-compatibility.md`. +- The runner defaults to `https://esm.unpkg.com` and can be pointed at beta/staging with `ESM_UNPKG_ORIGIN`. + +## Acceptance Criteria + +- `https://esm.unpkg.com/react@18.3.1` returns valid browser ESM with correct headers. +- `https://esm.unpkg.com/react-dom@18.3.1/client` resolves the subpath correctly. +- `?deps`, `?alias`, `?external`, `?bundle=false`, `?no-bundle`, and `?standalone` produce distinct, deterministic artifacts. +- `?dev` and `?env=production` select the correct package conditions and `NODE_ENV` replacement. +- TypeScript and TSX package sources transform successfully. +- `?meta` returns resolved version, artifact URL, types URL when available, exports, dependencies, and integrity. +- Import-map workflows work for external dependencies. +- Browser tests show a large request-count reduction for a D3-style bundled package. +- The top-100 npm compatibility suite passes at an agreed threshold, with failures classified by diagnostic code. diff --git a/package.json b/package.json index b297f689..e09a8e81 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,25 @@ "name": "unpkg-dev", "private": true, "type": "module", + "packageManager": "pnpm@10.15.0", "engines": { - "node": ">=23" + "bun": ">=1.2.8" }, "scripts": { "build": "pnpm -r run build", "clean": "git clean -fdX .", - "test": "pnpm -r test" + "clean:reports": "node -e \"fs.rmSync('.reports', { recursive: true, force: true }); fs.mkdirSync('.reports', { recursive: true });\"", + "deploy:app": "pnpm --filter unpkg-app run deploy", + "deploy:esm": "pnpm --filter unpkg-esm run deploy", + "deploy:www": "pnpm --filter unpkg-www run deploy", + "deploy:workers": "pnpm run deploy:www && pnpm run deploy:app && pnpm run deploy:esm", + "deploy:workers:staging": "pnpm --filter unpkg-www run deploy:staging && pnpm --filter unpkg-app run deploy:staging && pnpm --filter unpkg-esm run deploy:staging", + "pretest": "pnpm run build", + "test": "pnpm -r test", + "test:esm-browser": "bun scripts/esm-browser-smoke.ts", + "test:esm-readiness": "bun scripts/esm-beta-readiness.ts", + "test:esm-compat": "bun scripts/esm-compat-suite.ts", + "test:esm-compat:local-baseline": "ESM_SH_ORIGIN=http://localhost:8081 bun scripts/esm-compat-suite.ts", + "vendor:esm-sh": "scripts/esm-sh-baseline/run.sh" } } diff --git a/packages/unpkg-app/.gitignore b/packages/unpkg-app/.gitignore index 03fbfc53..3602c1b1 100644 --- a/packages/unpkg-app/.gitignore +++ b/packages/unpkg-app/.gitignore @@ -1,2 +1,3 @@ +.wrangler/ assets-manifest.json public/_assets/* \ No newline at end of file diff --git a/packages/unpkg-app/README.md b/packages/unpkg-app/README.md new file mode 100644 index 00000000..4ec8fc6b --- /dev/null +++ b/packages/unpkg-app/README.md @@ -0,0 +1,27 @@ +# unpkg-app + +This packages is the UNPKG web app. It is built and deployed as a [Cloudflare Worker](https://workers.cloudflare.com/). + +## Development + +Install dependencies and run the tests: + +``` +pnpm install +pnpm test +``` + +Boot the dev server and assets server (two separate tabs): + +``` +pnpm dev +pnpm dev:assets +``` + +## Deploying + +Edit the worker configuration in `wrangler.json`, then: + +``` +pnpm run deploy +``` diff --git a/packages/unpkg-app/fly.json b/packages/unpkg-app/fly.json deleted file mode 100644 index 93282047..00000000 --- a/packages/unpkg-app/fly.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "app": "unpkg-app", - "primary_region": "lax", - "build": { - "dockerfile": "Dockerfile", - "args": { - "MODE": "production" - } - }, - "http_service": { - "internal_port": 3000, - "auto_start_machines": true, - "min_machines_running": 2, - "processes": ["app"], - "checks": [ - { - "grace_period": "10s", - "interval": "1m0s", - "method": "get", - "path": "/_health", - "timeout": "5s" - } - ] - }, - "vm": [{ "size": "shared-cpu-8x" }] -} diff --git a/packages/unpkg-app/fly.staging.json b/packages/unpkg-app/fly.staging.json deleted file mode 100644 index 073b64c4..00000000 --- a/packages/unpkg-app/fly.staging.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "app": "unpkg-app-staging", - "primary_region": "lax", - "build": { - "dockerfile": "Dockerfile", - "args": { - "MODE": "staging" - } - }, - "http_service": { - "internal_port": 3000, - "auto_start_machines": true, - "min_machines_running": 1, - "processes": ["app"], - "checks": [ - { - "grace_period": "10s", - "interval": "1m0s", - "method": "get", - "path": "/_health", - "timeout": "5s" - } - ] - }, - "vm": [{ "size": "shared-cpu-8x" }] -} diff --git a/packages/unpkg-app/package.json b/packages/unpkg-app/package.json index 9644cef8..a8873ed6 100644 --- a/packages/unpkg-app/package.json +++ b/packages/unpkg-app/package.json @@ -4,37 +4,31 @@ "private": true, "type": "module", "dependencies": { - "chalk": "^5.4.1", "highlight.js": "^11.11.1", - "mrmime": "^2.0.0", "preact": "^10.25.2", "preact-render-to-string": "^6.5.12", "pretty-bytes": "^6.1.1", "semver": "^7.6.3", - "unpkg-tools": "workspace:^" + "unpkg-worker": "workspace:*" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20250320.0", "@ryanto/esbuild-plugin-tailwind": "^0.0.1", "@types/bun": "^1.2.8", "@types/node": "^22.10.2", "@types/semver": "^7.5.8", "esbuild": "^0.24.2", "tailwindcss": "4.0.0-beta.9", - "typescript": "^5.7.2" + "wrangler": "^4.3.0" }, "scripts": { - "build": "pnpm run build:assets && pnpm run build:server", - "build:assets": "node --disable-warning=ExperimentalWarning ../../scripts/build-assets.ts", - "build:server": "tsc --project ./tsconfig.build.json", - "dev": "MODE=development bun --port=3001 ./src/server.ts", + "build": "wrangler deploy --dry-run --outdir=dist", + "build:assets": "bun ../../scripts/build-assets.ts", + "dev": "wrangler dev --env dev --port 3001", "dev:assets": "bun ../../scripts/serve-assets.ts", "test": "bun --preload=./test/setup.ts test", - "deploy": "cd ../.. && fly deploy . -c ./packages/unpkg-app/fly.json", - "deploy:staging": "cd ../.. && fly deploy . -c ./packages/unpkg-app/fly.staging.json" - }, - "files": [ - "assets-manifest.json", - "dist", - "public" - ] + "predeploy": "pnpm run build:assets", + "deploy": "../../scripts/with-local-env.sh wrangler deploy", + "deploy:staging": "../../scripts/with-local-env.sh wrangler deploy --env staging" + } } diff --git a/packages/unpkg-app/src/assets-manifest.ts b/packages/unpkg-app/src/assets-manifest.ts index 3312b215..f27f067b 100644 --- a/packages/unpkg-app/src/assets-manifest.ts +++ b/packages/unpkg-app/src/assets-manifest.ts @@ -1,46 +1,33 @@ -import * as path from "node:path"; -import * as fsp from "node:fs/promises"; - -import { env } from "./env.ts"; - -const __dirname = path.dirname(new URL(import.meta.url).pathname); -const rootDir = path.resolve(__dirname, ".."); +import type { Env } from "./env.ts"; /** * A map of entry points to their URLs. */ export type AssetsManifest = Map; -export async function loadAssetsManifest(): Promise { - let mod: Record; +export async function loadAssetsManifest(env: Env): Promise { + let mod: { default: Record }; switch (env.MODE) { case "development": case "test": - mod = await loadJson(path.join(rootDir, "assets-manifest.dev.json")); + mod = await import("../assets-manifest.dev.json"); break; case "production": case "staging": try { - mod = await loadJson(path.join(rootDir, "assets-manifest.json")); + // @ts-ignore - This file is generated at build time + mod = await import("../assets-manifest.json"); } catch (error) { - throw new Error("Failed to load assets-manifest.json. Did you run `pnpm run build:assets`?"); + throw new Error("Failed to load assets-manifest.json. Did you run `pnpm build:assets`?"); } break; } let manifest: AssetsManifest = new Map(); - for (let [entryPoint, path] of Object.entries(mod)) { - if (env.ASSETS_ORIGIN) { - manifest.set(entryPoint, new URL(path, env.ASSETS_ORIGIN).href); - } else { - manifest.set(entryPoint, path); - } + for (let [entryPoint, path] of Object.entries(mod.default)) { + manifest.set(entryPoint, new URL(path, env.ASSETS_ORIGIN).href); } return manifest; } - -async function loadJson(file: string): Promise> { - return JSON.parse(await fsp.readFile(file, "utf-8")); -} diff --git a/packages/unpkg-app/src/components/document.tsx b/packages/unpkg-app/src/components/document.tsx index 4d0cabda..81d8c11e 100644 --- a/packages/unpkg-app/src/components/document.tsx +++ b/packages/unpkg-app/src/components/document.tsx @@ -3,27 +3,29 @@ import { type VNode } from "preact"; import { useAsset } from "../hooks.ts"; import { type ImportMap } from "../import-map.ts"; -const importMap: ImportMap = { - imports: { - preact: "https://unpkg.com/preact@10.25.4/dist/preact.module.js", - "preact/hooks": "https://unpkg.com/preact@10.25.4/hooks/dist/hooks.module.js", - "preact/jsx-runtime": "https://unpkg.com/preact@10.25.4/jsx-runtime/dist/jsxRuntime.module.js", - }, -}; - export function Document({ children, description = "The CDN for everything on npm", title = "UNPKG", subtitle, + wwwOrigin = "https://unpkg.com", }: { children?: VNode | VNode[]; description?: string; title?: string; subtitle?: string; + wwwOrigin?: string; }): VNode { + let importMap: ImportMap = { + imports: { + preact: new URL("/preact@10.25.4/dist/preact.module.js", wwwOrigin).href, + "preact/hooks": new URL("/preact@10.25.4/hooks/dist/hooks.module.js", wwwOrigin).href, + "preact/jsx-runtime": new URL("/preact@10.25.4/jsx-runtime/dist/jsxRuntime.module.js", wwwOrigin).href, + }, + }; + return ( - + @@ -36,7 +38,7 @@ export function Document({ - {subtitle == null ? title : `UNPKG • ${subtitle}`} + {subtitle == null ? title : `${title} • ${subtitle}`} - {children} + {children} ); } diff --git a/packages/unpkg-app/src/components/file-detail.tsx b/packages/unpkg-app/src/components/file-detail.tsx index a606be00..f561182b 100644 --- a/packages/unpkg-app/src/components/file-detail.tsx +++ b/packages/unpkg-app/src/components/file-detail.tsx @@ -1,9 +1,9 @@ import { type VNode } from "preact"; import prettyBytes from "pretty-bytes"; -import { type PackageInfo, type PackageFile } from "unpkg-tools"; +import type { PackageInfo, PackageFile } from "unpkg-worker"; -import * as hrefs from "../hrefs.ts"; import { highlightCode } from "../highlight.ts"; +import { useHrefs } from "../hooks.ts"; import { getLanguageName } from "../language-names.ts"; import { CodeViewer } from "./code-viewer.tsx"; @@ -14,7 +14,7 @@ import { Hydrate } from "./hydrate.tsx"; import { ImageViewer } from "./image-viewer.tsx"; // The maximum number of characters we are willing to show and apply highlighting. -const maxTextSize = 1024 * 1024; +const maxTextSize = 50_000; export function FileDetail({ packageInfo, @@ -27,6 +27,7 @@ export function FileDetail({ filename: string; file: PackageFile; }): VNode { + let hrefs = useHrefs(); let rawHref = hrefs.raw(packageInfo.name, version, filename); let lines: string[] | undefined; diff --git a/packages/unpkg-app/src/components/file-listing.tsx b/packages/unpkg-app/src/components/file-listing.tsx index 3ae18099..51325a1f 100644 --- a/packages/unpkg-app/src/components/file-listing.tsx +++ b/packages/unpkg-app/src/components/file-listing.tsx @@ -1,9 +1,9 @@ import { type VNode, Fragment } from "preact"; import prettyBytes from "pretty-bytes"; -import { type PackageInfo, type PackageFileMetadata } from "unpkg-tools"; +import type { PackageInfo, PackageFileMetadata } from "unpkg-worker"; -import * as hrefs from "../hrefs.ts"; import { parseGitHubRepo, createGitHubUrl } from "../github.ts"; +import { useHrefs } from "../hooks.ts"; import { FilesHeader } from "./files-header.tsx"; import { FilesLayout } from "./files-layout.tsx"; @@ -55,6 +55,8 @@ function FileListingContent({ dirname: string; files: PackageFileMetadata[]; }): VNode { + let hrefs = useHrefs(); + let parentHref: string | null = null; if (dirname !== "/") { let names = dirname.split("/").filter(Boolean); @@ -193,6 +195,8 @@ function FileListingContent({ } function FileListingSidebar({ packageInfo, version }: { packageInfo: PackageInfo; version: string }): VNode { + let hrefs = useHrefs(); + let latestVersion = packageInfo["dist-tags"]!.latest; let latestVersionDate = new Date(packageInfo.time[latestVersion]); let packageJson = packageInfo.versions![version]; diff --git a/packages/unpkg-app/src/components/files-header.tsx b/packages/unpkg-app/src/components/files-header.tsx index c5509c86..a81c6589 100644 --- a/packages/unpkg-app/src/components/files-header.tsx +++ b/packages/unpkg-app/src/components/files-header.tsx @@ -1,9 +1,9 @@ import { type VNode } from "preact"; import { compare as compareVersions } from "semver"; -import { type PackageInfo } from "unpkg-tools"; +import { type PackageInfo } from "unpkg-worker"; -import * as hrefs from "../hrefs.ts"; import { parseGitHubRepo, createGitHubUrl } from "../github.ts"; +import { useHrefs } from "../hooks.ts"; import { Hydrate } from "./hydrate.tsx"; import { VersionSelector } from "./version-selector.tsx"; @@ -18,8 +18,11 @@ export function FilesHeader({ version: string; filename: string; }): VNode { + let hrefs = useHrefs(); + let availableTags = packageInfo["dist-tags"]!; let availableVersions = Object.keys(packageInfo.versions!).sort((a, b) => compareVersions(b, a)); + let pathnameFormat = new URL(hrefs.files(packageInfo.name, "%s", filename)).pathname; let packageJson = packageInfo.versions![version]; @@ -60,7 +63,7 @@ export function FilesHeader({ availableTags={availableTags} availableVersions={availableVersions} currentVersion={version} - pathnameFormat={hrefs.files(packageInfo.name, "%s", filename)} + pathnameFormat={pathnameFormat} class="w-28 p-1 border border-slate-300 bg-slate-100 text-sm" /> diff --git a/packages/unpkg-app/src/components/files-layout.tsx b/packages/unpkg-app/src/components/files-layout.tsx index 9282cf82..2d746c24 100644 --- a/packages/unpkg-app/src/components/files-layout.tsx +++ b/packages/unpkg-app/src/components/files-layout.tsx @@ -1,9 +1,11 @@ import { type VNode, Fragment } from "preact"; -import * as hrefs from "../hrefs.ts"; +import { useHrefs } from "../hooks.ts"; import { GitHubIcon } from "./icons.tsx"; export function FilesLayout({ children }: { children: VNode | VNode[] }): VNode { + let hrefs = useHrefs(); + return (
diff --git a/packages/unpkg-app/src/components/files-nav.tsx b/packages/unpkg-app/src/components/files-nav.tsx index 10083933..b38f7939 100644 --- a/packages/unpkg-app/src/components/files-nav.tsx +++ b/packages/unpkg-app/src/components/files-nav.tsx @@ -1,7 +1,7 @@ import { type VNode } from "preact"; -import { type PackageInfo } from "unpkg-tools"; +import { type PackageInfo } from "unpkg-worker"; -import * as hrefs from "../hrefs.ts"; +import { useHrefs } from "../hooks.ts"; export function FilesNav({ packageInfo, @@ -12,6 +12,8 @@ export function FilesNav({ version: string; filename: string; }): VNode { + let hrefs = useHrefs(); + let breadcrumbs = [ filename === "/" ? ( {packageInfo.name} @@ -38,12 +40,12 @@ export function FilesNav({ {part} - , + ); } return acc; - }, [] as VNode[]), + }, [] as VNode[]) ); return ; diff --git a/packages/unpkg-app/src/env.ts b/packages/unpkg-app/src/env.ts index a2e50fbf..5f9b39ae 100644 --- a/packages/unpkg-app/src/env.ts +++ b/packages/unpkg-app/src/env.ts @@ -1,50 +1,8 @@ export interface Env { - APP_ORIGIN: string; ASSETS_ORIGIN: string; - DEBUG: boolean; DEV: boolean; + FILES_ORIGIN: string; MODE: "development" | "production" | "staging" | "test"; + ORIGIN: string; WWW_ORIGIN: string; } - -const envs: Record = { - development: { - APP_ORIGIN: "http://localhost:3001", - ASSETS_ORIGIN: "http://localhost:8001", - DEBUG: true, - DEV: true, - MODE: "development", - WWW_ORIGIN: "http://localhost:3000", - }, - production: { - APP_ORIGIN: "https://app.unpkg.com", - ASSETS_ORIGIN: "", - DEBUG: !!(process.env.DEBUG ?? false), - DEV: false, - MODE: "production", - WWW_ORIGIN: "https://unpkg.com", - }, - staging: { - APP_ORIGIN: "https://app.unpkg.dev", - ASSETS_ORIGIN: "", - DEBUG: !!(process.env.DEBUG ?? false), - DEV: false, - MODE: "staging", - WWW_ORIGIN: "https://unpkg.dev", - }, - test: { - APP_ORIGIN: "https://app.unpkg.com", - ASSETS_ORIGIN: "", - DEBUG: false, - DEV: false, - MODE: "test", - WWW_ORIGIN: "https://unpkg.com", - }, -}; - -let mode = (process.env.MODE ?? "development") as Env["MODE"]; -if (!(mode in envs)) { - throw new Error(`Invalid MODE: ${mode}`); -} - -export const env = envs[mode]; diff --git a/packages/unpkg-app/src/hooks.ts b/packages/unpkg-app/src/hooks.ts index f58d57d7..bf5f4858 100644 --- a/packages/unpkg-app/src/hooks.ts +++ b/packages/unpkg-app/src/hooks.ts @@ -1,12 +1,18 @@ import { useContext } from "preact/hooks"; import { AssetsContext } from "./assets-context.ts"; +import { HrefsContext } from "./hrefs-context.ts"; +import type { HrefBuilder } from "./href-builder.ts"; export function useAsset(entryPoint: string): string { let manifest = useContext(AssetsContext); - let asset = manifest.get(entryPoint); if (asset) return asset; - throw new Error(`Asset not found: ${entryPoint}`); } + +export function useHrefs(): HrefBuilder { + let hrefs = useContext(HrefsContext); + if (hrefs) return hrefs; + throw new Error("Hrefs context not found"); +} diff --git a/packages/unpkg-app/src/href-builder.ts b/packages/unpkg-app/src/href-builder.ts new file mode 100644 index 00000000..817acb3d --- /dev/null +++ b/packages/unpkg-app/src/href-builder.ts @@ -0,0 +1,25 @@ +import type { Env } from "./env.ts"; + +export class HrefBuilder { + #env: Env; + + constructor(env: Env) { + this.#env = env; + } + + files(packageName: string, version?: string, filename?: string): string { + // The /files prefix is not needed for the root of the file browser. + let path = filename == null || filename === "/" ? "" : `/files${filename.replace(/\/+$/, "")}`; + let url = new URL(`/${packageName}${version ? `@${version}` : ""}${path}`, this.#env.ORIGIN); + return url.href; + } + + home(): string { + return this.#env.WWW_ORIGIN; + } + + raw(packageName: string, version: string, filename: string): string { + let url = new URL(`/${packageName}@${version}${filename}`, this.#env.WWW_ORIGIN); + return url.href; + } +} diff --git a/packages/unpkg-app/src/hrefs-context.ts b/packages/unpkg-app/src/hrefs-context.ts new file mode 100644 index 00000000..e383d94a --- /dev/null +++ b/packages/unpkg-app/src/hrefs-context.ts @@ -0,0 +1,5 @@ +import { createContext } from "preact"; + +import type { HrefBuilder } from "./href-builder.ts"; + +export const HrefsContext = createContext(null); diff --git a/packages/unpkg-app/src/hrefs.ts b/packages/unpkg-app/src/hrefs.ts deleted file mode 100644 index 49f9209d..00000000 --- a/packages/unpkg-app/src/hrefs.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { env } from "./env.ts"; - -export function files(packageName: string, version?: string, filename?: string): string { - // The /files prefix is not needed for the root of the file browser. - let path = filename == null || filename === "/" ? "" : `/files${filename.replace(/\/+$/, "")}`; - return `/${packageName}${version ? `@${version}` : ""}${path}`; -} - -export function home(): string { - return env.WWW_ORIGIN; -} - -export function raw(packageName: string, version: string, filename: string): string { - let url = new URL(`/${packageName}@${version}${filename}`, env.WWW_ORIGIN); - return url.href; -} diff --git a/packages/unpkg-app/src/public-assets.ts b/packages/unpkg-app/src/public-assets.ts deleted file mode 100644 index 7dcc3ad6..00000000 --- a/packages/unpkg-app/src/public-assets.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as path from "node:path"; - -const __dirname = path.dirname(new URL(import.meta.url).pathname); -const publicDir = path.resolve(__dirname, "..", "public"); - -export async function findPublicAsset(filename: string): Promise { - let file = Bun.file(path.join(publicDir, filename)); - if (await file.exists()) return file; - return null; -} diff --git a/packages/unpkg-app/src/request-handler.test.ts b/packages/unpkg-app/src/request-handler.test.ts index a3d84824..8a01f0ad 100644 --- a/packages/unpkg-app/src/request-handler.test.ts +++ b/packages/unpkg-app/src/request-handler.test.ts @@ -1,27 +1,69 @@ import { expect, describe, it, beforeAll, afterAll } from "bun:test"; import { packageInfo } from "../test/fixtures.ts"; +import type { Env } from "./env.ts"; import { handleRequest } from "./request-handler.tsx"; +const env: Env = { + ASSETS_ORIGIN: "https://app.unpkg.com", + DEV: false, + FILES_ORIGIN: "https://files.unpkg.com", + MODE: "test", + ORIGIN: "https://app.unpkg.com", + WWW_ORIGIN: "https://unpkg.com", +}; + +const context = { + waitUntil() {}, +} as unknown as ExecutionContext; + function dispatchFetch(input: RequestInfo | URL, init?: RequestInit): Promise { let request = input instanceof Request ? input : new Request(input, init); - return handleRequest(request); + return handleRequest(request, env, context); +} + +function fileResponse(path: string): Response { + return new Response(Bun.file(path)); } describe("handleRequest", () => { + let globalCaches: CacheStorage | undefined; let globalFetch: typeof fetch | undefined; - function fileResponse(path: string): Response { - return new Response(Bun.file(path)); - } - beforeAll(() => { + globalCaches = globalThis.caches; globalFetch = globalThis.fetch; - // Does not implement Bun's non-spec fetch.preconnect API - https://bun.sh/docs/api/fetch#preconnect-to-a-host - // @ts-expect-error - globalThis.fetch = async (input: RequestInfo | URL) => { - let url = input instanceof Request ? input.url : input; + globalThis.caches = { + async open() { + return { + async match() { + return null; + }, + async put() {}, + }; + }, + } as unknown as CacheStorage; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + let request = input instanceof Request ? input : new Request(input); + let url = new URL(request.url); + + if (url.origin === env.FILES_ORIGIN) { + return Response.json({ + package: "react", + version: "18.2.0", + prefix: "/", + files: [ + { + path: "/package.json", + size: 999, + type: "application/json", + integrity: "sha256-test", + }, + ], + }); + } switch (url.toString()) { case "https://registry.npmjs.org/react": @@ -29,13 +71,12 @@ describe("handleRequest", () => { default: throw new Error(`Unexpected URL: ${url}`); } - }; + }) as unknown as typeof fetch; }); afterAll(() => { - if (globalFetch) { - globalThis.fetch = globalFetch; - } + if (globalCaches) globalThis.caches = globalCaches; + if (globalFetch) globalThis.fetch = globalFetch; }); it("redirects / to unpkg.com", async () => { @@ -50,28 +91,36 @@ describe("handleRequest", () => { let response = await dispatchFetch("https://app.unpkg.com/react@18.2.0/", { redirect: "manual" }); expect(response.status).toBe(301); let location = response.headers.get("Location"); - expect(location).toBe("https://app.unpkg.com/react@18.2.0"); + expect(location).toBe("/react@18.2.0"); }); it('redirects "/:package/files" to "/package@version"', async () => { let response = await dispatchFetch("https://app.unpkg.com/react/files", { redirect: "manual" }); expect(response.status).toBe(301); let location = response.headers.get("Location"); - expect(location).toMatch(/^https:\/\/app\.unpkg\.com\/react@\d+\.\d+\.\d+$/); + expect(location).toMatch(/^\/react@\d+\.\d+\.\d+$/); }); it("matches package names in any case", async () => { let response = await dispatchFetch("https://app.unpkg.com/React/files", { redirect: "manual" }); expect(response.status).toBe(301); let location = response.headers.get("Location"); - expect(location).toMatch(/^https:\/\/app\.unpkg\.com\/react@\d+\.\d+\.\d+$/); + expect(location).toMatch(/^\/react@\d+\.\d+\.\d+$/); }); it('redirects "/:package@:version/files" to "/package@version"', async () => { let response = await dispatchFetch("https://app.unpkg.com/react@18.2.0/files", { redirect: "manual" }); expect(response.status).toBe(301); let location = response.headers.get("Location"); - expect(location).toBe("https://app.unpkg.com/react@18.2.0"); + expect(location).toBe("/react@18.2.0"); + }); + + it("renders package pages with an explicit white background", async () => { + let response = await dispatchFetch("https://app.unpkg.com/react@18.2.0"); + let html = await response.text(); + + expect(html).toContain(''); + expect(html).toContain(''); }); it("resolves semver range on package root", async () => { @@ -79,7 +128,7 @@ describe("handleRequest", () => { expect(response.status).toBe(302); let location = response.headers.get("Location"); expect(location).toBeTruthy(); - expect(location).toMatch(/^https:\/\/app\.unpkg\.com\/react@18\.\d+\.\d+$/); + expect(location).toMatch(/^\/react@18\.\d+\.\d+$/); }); it("resolves semver range on specific filename", async () => { @@ -87,7 +136,7 @@ describe("handleRequest", () => { expect(response.status).toBe(302); let location = response.headers.get("Location"); expect(location).toBeTruthy(); - expect(location).toMatch(/^https:\/\/app\.unpkg\.com\/react@18\.\d+\.\d+\/files\/index\.js$/); + expect(location).toMatch(/^\/react@18\.\d+\.\d+\/files\/index\.js$/); }); it("resolves http: protocol to https:", async () => { @@ -95,7 +144,7 @@ describe("handleRequest", () => { expect(response.status).toBe(302); let location = response.headers.get("Location"); expect(location).toBeTruthy(); - expect(location).toMatch(/^https:\/\/app\.unpkg\.com\/react@18\.\d+\.\d+\/files\/index\.js$/); + expect(location).toMatch(/^\/react@18\.\d+\.\d+\/files\/index\.js$/); }); it("resolves npm tags", async () => { @@ -103,6 +152,6 @@ describe("handleRequest", () => { expect(response.status).toBe(302); let location = response.headers.get("Location"); expect(location).toBeTruthy(); - expect(location).toMatch(/^https:\/\/app\.unpkg\.com\/react@\d+\.\d+\.\d+\/files\/index\.js$/); + expect(location).toMatch(/^\/react@\d+\.\d+\.\d+\/files\/index\.js$/); }); }); diff --git a/packages/unpkg-app/src/request-handler.tsx b/packages/unpkg-app/src/request-handler.tsx index 5c1cd6b8..84c7d321 100644 --- a/packages/unpkg-app/src/request-handler.tsx +++ b/packages/unpkg-app/src/request-handler.tsx @@ -1,43 +1,20 @@ import { type VNode } from "preact"; import { render } from "preact-render-to-string"; -import { getFile, getPackageInfo, listFiles, parsePackagePathname, resolvePackageVersion } from "unpkg-tools"; +import { getFile, getPackageInfo, listFiles, parsePackagePathname, resolvePackageVersion } from "unpkg-worker"; import { AssetsContext } from "./assets-context.ts"; import { loadAssetsManifest } from "./assets-manifest.ts"; -import { logRequest } from "./request-logging.ts"; -import { env } from "./env.ts"; -import { findPublicAsset } from "./public-assets.ts"; import { Document } from "./components/document.tsx"; import { FileDetail } from "./components/file-detail.tsx"; import { FileListing } from "./components/file-listing.tsx"; import { NotFound } from "./components/not-found.tsx"; +import type { Env } from "./env.ts"; +import { HrefBuilder } from "./href-builder.ts"; +import { HrefsContext } from "./hrefs-context.ts"; const publicNpmRegistry = "https://registry.npmjs.org"; -export async function handleRequest(request: Request): Promise { - try { - let response: Response; - if (env.DEBUG) { - let start = Date.now(); - response = await handleRequest_(request); - logRequest(request, response, Date.now() - start); - } else { - response = await handleRequest_(request); - } - - if (request.method === "HEAD") { - return new Response(null, response); - } - - return response; - } catch (error) { - console.error(error); - - return new Response("Internal Server Error", { status: 500 }); - } -} - -async function handleRequest_(request: Request): Promise { +export async function handleRequest(request: Request, env: Env, context: ExecutionContext): Promise { if (request.method === "OPTIONS") { return new Response(null, { headers: { Allow: "GET, HEAD, OPTIONS" }, @@ -59,22 +36,13 @@ async function handleRequest_(request: Request): Promise { return redirect(env.WWW_ORIGIN, 301); } - let file = await findPublicAsset(url.pathname); - if (file != null) { - return new Response(file, { - headers: { - "Cache-Control": env.DEV ? "no-store" : "public, max-age=31536000", - }, - }); - } - let parsed = parsePackagePathname(url.pathname); if (parsed == null) { return notFound(`Invalid package pathname: ${url.pathname}`); } let packageName = parsed.package.toLowerCase(); - let packageInfo = await getPackageInfo(publicNpmRegistry, packageName); + let packageInfo = await getPackageInfo(context, publicNpmRegistry, packageName); if (packageInfo == null) { return notFound(`Package not found: "${packageName}"`); } @@ -87,24 +55,24 @@ async function handleRequest_(request: Request): Promise { if (parsed.filename != null && parsed.filename.endsWith("/")) { let noTrailingSlash = parsed.filename.replace(/\/+$/, ""); - return redirect(new URL(`/${packageName}@${version}${noTrailingSlash}`, env.APP_ORIGIN), 301); + return redirect(`/${packageName}@${version}${noTrailingSlash}`, 301); } if (parsed.filename === "/files") { - return redirect(new URL(`/${packageName}@${version}`, env.APP_ORIGIN), 301); + return redirect(`/${packageName}@${version}`, 301); } if (version !== parsed.version) { - return redirect(new URL(`/${packageName}@${version}${parsed.filename ?? ""}`, env.APP_ORIGIN), { + return redirect(`/${packageName}@${version}${parsed.filename ?? ""}`, { headers: { "Cache-Control": "public, max-age=60, s-maxage=300", }, }); } - let files = await listFiles(publicNpmRegistry, packageName, version, "/"); + let files = await listFiles(context, env.FILES_ORIGIN, packageName, version, "/"); let filename = parsed.filename ?? "/"; if (filename === "/") { - return renderPage(, { + return renderPage(env, , { headers: { "Cache-Control": "public, max-age=60, s-maxage=300", }, @@ -116,9 +84,10 @@ async function handleRequest_(request: Request): Promise { let matchingFile = files.find((file) => file.path === remainingFilename); if (matchingFile != null) { - let file = await getFile(publicNpmRegistry, packageName, version, remainingFilename); + let file = await getFile(context, env.FILES_ORIGIN, packageName, version, remainingFilename); return renderPage( + env, , { headers: { @@ -132,6 +101,7 @@ async function handleRequest_(request: Request): Promise { let matchingFiles = files.filter((file) => file.path.startsWith(dirname)); return renderPage( + env, , { headers: { @@ -141,7 +111,7 @@ async function handleRequest_(request: Request): Promise { ); } - return renderPage(, { + return renderPage(env, , { status: 404, }); } @@ -170,12 +140,15 @@ function redirect(location: string | URL, init?: ResponseInit | number): Respons }); } -async function renderPage(node: VNode, init?: ResponseInit): Promise { - let assetsManifest = await loadAssetsManifest(); +async function renderPage(env: Env, node: VNode, init?: ResponseInit): Promise { + let assetsManifest = await loadAssetsManifest(env); + let hrefBuilder = new HrefBuilder(env); let html = render( - {node} + + {node} + ); diff --git a/packages/unpkg-app/src/server.ts b/packages/unpkg-app/src/server.ts deleted file mode 100644 index 3b9f2feb..00000000 --- a/packages/unpkg-app/src/server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { handleRequest } from "./request-handler.tsx"; - -let server = Bun.serve({ - fetch: handleRequest, -}); - -console.log(`Server listening on http://${server.hostname}:${server.port} ...`); -console.log(); diff --git a/packages/unpkg-app/src/worker.ts b/packages/unpkg-app/src/worker.ts new file mode 100644 index 00000000..efaba1a6 --- /dev/null +++ b/packages/unpkg-app/src/worker.ts @@ -0,0 +1,31 @@ +import type { Env } from "./env.ts"; +import { handleRequest } from "./request-handler.tsx"; + +// @ts-expect-error - `caches.default` is missing in @cloudflare/workers-types +const cache = caches.default as Cache; + +export default { + async fetch(request, env, context) { + try { + let response = await cache.match(request); + + if (!response) { + response = await handleRequest(request, env, context); + + if (request.method === "GET" && response.status === 200 && response.headers.has("Cache-Control")) { + context.waitUntil(cache.put(request, response.clone())); + } + } + + if (request.method === "HEAD") { + return new Response(null, response); + } + + return response; + } catch (error) { + console.error(error); + + return new Response("Internal Server Error", { status: 500 }); + } + }, +} satisfies ExportedHandler; diff --git a/packages/unpkg-app/tsconfig.json b/packages/unpkg-app/tsconfig.json index 906fd259..115bdd64 100644 --- a/packages/unpkg-app/tsconfig.json +++ b/packages/unpkg-app/tsconfig.json @@ -2,15 +2,13 @@ "compilerOptions": { "strict": true, "target": "ESNext", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ES2022", + "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "rewriteRelativeImportExtensions": true, - "resolveJsonModule": true, + "types": ["@cloudflare/workers-types", "bun", "node"], "jsx": "react-jsx", "jsxImportSource": "preact", - "skipLibCheck": true, - "outDir": "dist" + "noEmit": true } } diff --git a/packages/unpkg-app/wrangler.json b/packages/unpkg-app/wrangler.json new file mode 100644 index 00000000..3b2bc0dc --- /dev/null +++ b/packages/unpkg-app/wrangler.json @@ -0,0 +1,47 @@ +{ + "compatibility_date": "2024-12-05", + "main": "./src/worker.ts", + "assets": { "directory": "./public" }, + "build": { "command": "pnpm run build:assets" }, + + "name": "unpkg-app", + "observability": { "enabled": true, "head_sampling_rate": 0.001 }, + "routes": [{ "pattern": "app.unpkg.com", "custom_domain": true }], + "vars": { + "ASSETS_ORIGIN": "https://app.unpkg.com", + "DEV": false, + "FILES_ORIGIN": "https://fly.unpkg.com", + "MODE": "production", + "ORIGIN": "https://app.unpkg.com", + "WWW_ORIGIN": "https://unpkg.com" + }, + + "env": { + "dev": { + "name": "unpkg-app-dev", + "routes": [], + "vars": { + "ASSETS_ORIGIN": "http://localhost:8001", + "DEV": true, + "FILES_ORIGIN": "http://localhost:4000", + "MODE": "development", + "ORIGIN": "http://localhost:3001", + "WWW_ORIGIN": "http://localhost:3000" + } + }, + + "staging": { + "name": "unpkg-app-staging", + "observability": { "enabled": true }, + "routes": [{ "pattern": "app.unpkg.dev", "custom_domain": true }], + "vars": { + "ASSETS_ORIGIN": "https://app.unpkg.dev", + "DEV": false, + "FILES_ORIGIN": "https://fly.unpkg.dev", + "MODE": "staging", + "ORIGIN": "https://app.unpkg.dev", + "WWW_ORIGIN": "https://unpkg.dev" + } + } + } +} diff --git a/packages/unpkg-esm/package.json b/packages/unpkg-esm/package.json new file mode 100644 index 00000000..98fe8ed6 --- /dev/null +++ b/packages/unpkg-esm/package.json @@ -0,0 +1,26 @@ +{ + "name": "unpkg-esm", + "description": "The ESM package import service for UNPKG", + "private": true, + "type": "module", + "dependencies": { + "highlight.js": "^11.11.1", + "preact": "^10.25.2", + "preact-render-to-string": "^6.5.12", + "unpkg-worker": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250320.0", + "@types/bun": "^1.2.8", + "@types/node": "^22.10.2", + "unpkg-files": "workspace:*", + "wrangler": "^4.3.0" + }, + "scripts": { + "build": "wrangler deploy --dry-run --outdir=dist", + "dev": "wrangler dev --env dev --port 3002", + "test": "bun --preload=./test/setup.ts test", + "deploy": "../../scripts/with-local-env.sh wrangler deploy", + "deploy:staging": "../../scripts/with-local-env.sh wrangler deploy --env staging" + } +} diff --git a/packages/unpkg-esm/public/favicon.jpg b/packages/unpkg-esm/public/favicon.jpg new file mode 100644 index 00000000..a8528810 Binary files /dev/null and b/packages/unpkg-esm/public/favicon.jpg differ diff --git a/packages/unpkg-esm/src/components/home-page.tsx b/packages/unpkg-esm/src/components/home-page.tsx new file mode 100644 index 00000000..5a8a2144 --- /dev/null +++ b/packages/unpkg-esm/src/components/home-page.tsx @@ -0,0 +1,310 @@ +import highlight from "highlight.js/lib/common"; +import type { ComponentChildren, VNode } from "preact"; +import { render } from "preact-render-to-string"; + +import type { Env } from "../env.ts"; +import { GitHubIcon, XIcon } from "./icons.tsx"; + +const homePageExample = ``; + +export function createHomePage(env: Env): string { + let wwwOrigin = env.WWW_ORIGIN.replace(/\/+$/, ""); + let esmOrigin = env.ORIGIN.replace(/\/+$/, ""); + let exampleHtml = highlight.highlight(homePageExample.replaceAll("%%ESM_ORIGIN%%", esmOrigin), { + language: "xml", + }).value; + + return ( + "" + + render( + +
+
+
+
+

+ esm.unpkg.com is currently in beta. It serves browser-ready ES modules from npm + packages using UNPKG infrastructure. Use it when a package is not already published as browser-ready ESM + and you want to load it directly in modern browsers without a build step. +

+ +

+ {esmOrigin}/:package@:version/:subpath +

+
+ +
+

Example

+

+ Import packages from esm.unpkg.com in a module script: +

+ +
+ +
+
+ +
+

Usage

+
    +
  • + Omit the version to use the package's latest npm tag. +
  • +
  • Use npm dist-tags, semver ranges, or exact versions in the URL.
  • +
  • + Add ?target=es2022 to choose an output target. +
  • +
  • + Add ?dev for development builds. +
  • +
  • + Add ?bundle, ?standalone, or ?no-bundle to control bundling. +
  • +
  • + Add ?meta to inspect resolved module metadata. +
  • +
+
+ +
+

Documentation

+

+ For official UNPKG documentation, including package URLs, exports, metadata, import maps, and browser + module options, visit the main UNPKG home page. The browser modules section + is available at {wwwOrigin}/#browser-modules. +

+
+
+
+